@risingwave/wavelet-server 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/__tests__/cursor-parsing.test.d.ts +2 -0
  2. package/dist/__tests__/cursor-parsing.test.d.ts.map +1 -0
  3. package/dist/__tests__/cursor-parsing.test.js +64 -0
  4. package/dist/__tests__/cursor-parsing.test.js.map +1 -0
  5. package/dist/__tests__/http-api.test.d.ts +2 -0
  6. package/dist/__tests__/http-api.test.d.ts.map +1 -0
  7. package/dist/__tests__/http-api.test.js +135 -0
  8. package/dist/__tests__/http-api.test.js.map +1 -0
  9. package/dist/__tests__/integration.test.d.ts +2 -0
  10. package/dist/__tests__/integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/integration.test.js +229 -0
  12. package/dist/__tests__/integration.test.js.map +1 -0
  13. package/dist/__tests__/jwt.test.d.ts +2 -0
  14. package/dist/__tests__/jwt.test.d.ts.map +1 -0
  15. package/dist/__tests__/jwt.test.js +86 -0
  16. package/dist/__tests__/jwt.test.js.map +1 -0
  17. package/dist/__tests__/ws-fanout.test.d.ts +2 -0
  18. package/dist/__tests__/ws-fanout.test.d.ts.map +1 -0
  19. package/dist/__tests__/ws-fanout.test.js +127 -0
  20. package/dist/__tests__/ws-fanout.test.js.map +1 -0
  21. package/dist/config-loader.d.ts +3 -0
  22. package/dist/config-loader.d.ts.map +1 -0
  23. package/dist/config-loader.js +25 -0
  24. package/dist/config-loader.js.map +1 -0
  25. package/dist/cursor-manager.d.ts +54 -0
  26. package/dist/cursor-manager.d.ts.map +1 -0
  27. package/dist/cursor-manager.js +263 -0
  28. package/dist/cursor-manager.js.map +1 -0
  29. package/dist/ddl-manager.d.ts +33 -0
  30. package/dist/ddl-manager.d.ts.map +1 -0
  31. package/dist/ddl-manager.js +364 -0
  32. package/dist/ddl-manager.js.map +1 -0
  33. package/dist/http-api.d.ts +21 -0
  34. package/dist/http-api.d.ts.map +1 -0
  35. package/dist/http-api.js +242 -0
  36. package/dist/http-api.js.map +1 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +32 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/jwt.d.ts +16 -0
  42. package/dist/jwt.d.ts.map +1 -0
  43. package/dist/jwt.js +87 -0
  44. package/dist/jwt.js.map +1 -0
  45. package/dist/server.d.ts +24 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +82 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/webhook.d.ts +9 -0
  50. package/dist/webhook.d.ts.map +1 -0
  51. package/dist/webhook.js +63 -0
  52. package/dist/webhook.js.map +1 -0
  53. package/dist/ws-fanout.d.ts +24 -0
  54. package/dist/ws-fanout.d.ts.map +1 -0
  55. package/dist/ws-fanout.js +198 -0
  56. package/dist/ws-fanout.js.map +1 -0
  57. package/package.json +7 -3
  58. package/src/__tests__/cursor-parsing.test.ts +0 -68
  59. package/src/__tests__/http-api.test.ts +0 -130
  60. package/src/__tests__/integration.test.ts +0 -249
  61. package/src/__tests__/jwt.test.ts +0 -62
  62. package/src/__tests__/ws-fanout.test.ts +0 -143
  63. package/src/config-loader.ts +0 -28
  64. package/src/cursor-manager.ts +0 -311
  65. package/src/ddl-manager.ts +0 -408
  66. package/src/http-api.ts +0 -278
  67. package/src/index.ts +0 -31
  68. package/src/jwt.ts +0 -56
  69. package/src/server.ts +0 -89
  70. package/src/webhook.ts +0 -67
  71. package/src/ws-fanout.ts +0 -245
  72. package/tsconfig.json +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@risingwave/wavelet-server",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Wavelet server - WebSocket fanout layer for RisingWave",
5
5
  "homepage": "https://github.com/risingwavelabs/wavelet",
6
6
  "repository": {
@@ -13,16 +13,20 @@
13
13
  },
14
14
  "main": "./dist/index.js",
15
15
  "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist"
18
+ ],
16
19
  "scripts": {
17
20
  "build": "tsc",
18
21
  "dev": "tsx watch src/index.ts",
19
- "start": "node dist/index.js"
22
+ "start": "node dist/index.js",
23
+ "prepack": "npm --prefix ../config run build && npm run build"
20
24
  },
21
25
  "dependencies": {
22
26
  "pg": "^8.13.0",
23
27
  "ws": "^8.18.0",
24
28
  "jose": "^6.0.0",
25
- "@risingwave/wavelet": "0.2.4"
29
+ "@risingwave/wavelet": "0.2.5"
26
30
  },
27
31
  "devDependencies": {
28
32
  "@types/pg": "^8.11.0",
@@ -1,68 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { CursorManager } from '../cursor-manager.js'
3
-
4
- // Access private parseDiffs via cast
5
- function parseDiffs(rows: any[]) {
6
- const cm = new CursorManager('postgres://dummy', {})
7
- return (cm as any).parseDiffs(rows)
8
- }
9
-
10
- describe('parseDiffs', () => {
11
- it('parses Insert ops (string form)', () => {
12
- const rows = [
13
- { op: 'Insert', rw_timestamp: '100', player_id: 'alice', score: 42 },
14
- ]
15
- const diff = parseDiffs(rows)
16
- expect(diff.inserted).toHaveLength(1)
17
- expect(diff.inserted[0]).toEqual({ player_id: 'alice', score: 42 })
18
- expect(diff.deleted).toHaveLength(0)
19
- expect(diff.updated).toHaveLength(0)
20
- })
21
-
22
- it('parses Insert ops (numeric form)', () => {
23
- const rows = [
24
- { op: '1', rw_timestamp: '100', name: 'bob' },
25
- ]
26
- const diff = parseDiffs(rows)
27
- expect(diff.inserted).toHaveLength(1)
28
- expect(diff.inserted[0]).toEqual({ name: 'bob' })
29
- })
30
-
31
- it('parses Delete ops', () => {
32
- const rows = [
33
- { op: 'Delete', rw_timestamp: '200', player_id: 'alice' },
34
- ]
35
- const diff = parseDiffs(rows)
36
- expect(diff.deleted).toHaveLength(1)
37
- expect(diff.deleted[0]).toEqual({ player_id: 'alice' })
38
- })
39
-
40
- it('parses Update ops (UpdateDelete + UpdateInsert)', () => {
41
- const rows = [
42
- { op: 'UpdateDelete', rw_timestamp: '300', player_id: 'alice', score: 10 },
43
- { op: 'UpdateInsert', rw_timestamp: '300', player_id: 'alice', score: 20 },
44
- ]
45
- const diff = parseDiffs(rows)
46
- expect(diff.deleted).toHaveLength(1)
47
- expect(diff.deleted[0]).toEqual({ player_id: 'alice', score: 10 })
48
- expect(diff.updated).toHaveLength(1)
49
- expect(diff.updated[0]).toEqual({ player_id: 'alice', score: 20 })
50
- })
51
-
52
- it('returns empty diff for no rows', () => {
53
- const diff = parseDiffs([])
54
- expect(diff.inserted).toHaveLength(0)
55
- expect(diff.updated).toHaveLength(0)
56
- expect(diff.deleted).toHaveLength(0)
57
- expect(diff.cursor).toBe('')
58
- })
59
-
60
- it('extracts cursor from last rw_timestamp', () => {
61
- const rows = [
62
- { op: 'Insert', rw_timestamp: '100', x: 1 },
63
- { op: 'Insert', rw_timestamp: '200', x: 2 },
64
- ]
65
- const diff = parseDiffs(rows)
66
- expect(diff.cursor).toBe('200')
67
- })
68
- })
@@ -1,130 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import http from 'node:http'
3
- import { HttpApi } from '../http-api.js'
4
-
5
- function createTestServer(api: HttpApi): Promise<http.Server> {
6
- return new Promise((resolve) => {
7
- const server = http.createServer((req, res) => api.handle(req, res))
8
- server.listen(0, () => resolve(server))
9
- })
10
- }
11
-
12
- function request(server: http.Server, method: string, path: string, body?: unknown): Promise<{ status: number; data: any }> {
13
- const addr = server.address() as any
14
- return new Promise((resolve, reject) => {
15
- const req = http.request({
16
- hostname: 'localhost',
17
- port: addr.port,
18
- method,
19
- path,
20
- headers: body ? { 'Content-Type': 'application/json' } : {},
21
- }, (res) => {
22
- let data = ''
23
- res.on('data', (chunk) => { data += chunk })
24
- res.on('end', () => {
25
- try {
26
- resolve({ status: res.statusCode!, data: JSON.parse(data) })
27
- } catch {
28
- resolve({ status: res.statusCode!, data })
29
- }
30
- })
31
- })
32
- req.on('error', reject)
33
- if (body) req.write(JSON.stringify(body))
34
- req.end()
35
- })
36
- }
37
-
38
- describe('HttpApi', () => {
39
- const events = {
40
- game_events: { columns: { user_id: 'string' as const, value: 'int' as const } },
41
- }
42
- const queries = {
43
- leaderboard: { _tag: 'sql' as const, text: 'SELECT 1' },
44
- }
45
-
46
- it('returns health check', async () => {
47
- const api = new HttpApi('postgres://dummy', events, queries)
48
- const server = await createTestServer(api)
49
- try {
50
- const res = await request(server, 'GET', '/v1/health')
51
- expect(res.status).toBe(200)
52
- expect(res.data).toEqual({ status: 'ok' })
53
- } finally {
54
- server.close()
55
- }
56
- })
57
-
58
- it('lists available queries', async () => {
59
- const api = new HttpApi('postgres://dummy', events, queries)
60
- const server = await createTestServer(api)
61
- try {
62
- const res = await request(server, 'GET', '/v1/queries')
63
- expect(res.status).toBe(200)
64
- expect(res.data).toEqual({ queries: ['leaderboard'] })
65
- } finally {
66
- server.close()
67
- }
68
- })
69
-
70
- it('lists available events', async () => {
71
- const api = new HttpApi('postgres://dummy', events, queries)
72
- const server = await createTestServer(api)
73
- try {
74
- const res = await request(server, 'GET', '/v1/events')
75
- expect(res.status).toBe(200)
76
- expect(res.data).toEqual({ events: ['game_events'] })
77
- } finally {
78
- server.close()
79
- }
80
- })
81
-
82
- it('returns 404 for unknown routes with helpful message', async () => {
83
- const api = new HttpApi('postgres://dummy', events, queries)
84
- const server = await createTestServer(api)
85
- try {
86
- const res = await request(server, 'GET', '/v1/nonexistent')
87
- expect(res.status).toBe(404)
88
- expect(res.data.error).toBe('Not found')
89
- expect(res.data.routes).toBeDefined()
90
- expect(res.data.routes.length).toBeGreaterThan(0)
91
- } finally {
92
- server.close()
93
- }
94
- })
95
-
96
- it('handles CORS preflight', async () => {
97
- const api = new HttpApi('postgres://dummy', events, queries)
98
- const server = await createTestServer(api)
99
- try {
100
- const res = await request(server, 'OPTIONS', '/v1/queries')
101
- expect(res.status).toBe(204)
102
- } finally {
103
- server.close()
104
- }
105
- })
106
-
107
- it('returns 404 for unknown event', async () => {
108
- const api = new HttpApi('postgres://dummy', events, queries)
109
- const server = await createTestServer(api)
110
- try {
111
- const res = await request(server, 'POST', '/v1/events/nonexistent', { x: 1 })
112
- expect(res.status).toBe(404)
113
- expect(res.data.available_events).toEqual(['game_events'])
114
- } finally {
115
- server.close()
116
- }
117
- })
118
-
119
- it('returns 404 for unknown query', async () => {
120
- const api = new HttpApi('postgres://dummy', events, queries)
121
- const server = await createTestServer(api)
122
- try {
123
- const res = await request(server, 'GET', '/v1/queries/nonexistent')
124
- expect(res.status).toBe(404)
125
- expect(res.data.available_queries).toEqual(['leaderboard'])
126
- } finally {
127
- server.close()
128
- }
129
- })
130
- })
@@ -1,249 +0,0 @@
1
- /**
2
- * Integration tests that require a running RisingWave instance.
3
- * Skipped in CI unless WAVELET_TEST_DATABASE_URL is set.
4
- */
5
- import { describe, it, expect, beforeAll, afterAll } from 'vitest'
6
- import pg from 'pg'
7
- import http from 'node:http'
8
- import { WebSocket } from 'ws'
9
- import { DdlManager } from '../ddl-manager.js'
10
- import { WaveletServer } from '../server.js'
11
- import { sql } from '@risingwave/wavelet'
12
- import type { WaveletConfig } from '@risingwave/wavelet'
13
-
14
- const { Client } = pg
15
-
16
- const DATABASE_URL = process.env.WAVELET_TEST_DATABASE_URL ?? 'postgres://root@localhost:4566/dev'
17
-
18
- // Check if RisingWave is reachable
19
- async function isRisingWaveAvailable(): Promise<boolean> {
20
- const client = new Client({ connectionString: DATABASE_URL, connectionTimeoutMillis: 2000 })
21
- try {
22
- await client.connect()
23
- await client.query('SELECT 1')
24
- await client.end()
25
- return true
26
- } catch {
27
- return false
28
- }
29
- }
30
-
31
- // Use unique names to avoid collisions with other test runs
32
- const prefix = `test_${Date.now()}`
33
- const STREAM_NAME = `${prefix}_events`
34
- const VIEW_NAME = `${prefix}_totals`
35
- const SUB_NAME = `wavelet_sub_${VIEW_NAME}`
36
-
37
- const testConfig: WaveletConfig = {
38
- database: DATABASE_URL,
39
- events: {
40
- [STREAM_NAME]: {
41
- columns: {
42
- user_id: 'string',
43
- value: 'int',
44
- },
45
- },
46
- },
47
- queries: {
48
- [VIEW_NAME]: sql`
49
- SELECT user_id, SUM(value) AS total_value, COUNT(*) AS event_count
50
- FROM ${STREAM_NAME}
51
- GROUP BY user_id
52
- `,
53
- },
54
- server: {
55
- port: 0, // random port
56
- },
57
- }
58
-
59
- describe.runIf(process.env.WAVELET_INTEGRATION === '1')('Integration: DDL Manager', () => {
60
- let ddl: DdlManager
61
-
62
- beforeAll(async () => {
63
- ddl = new DdlManager(DATABASE_URL)
64
- await ddl.connect()
65
- })
66
-
67
- afterAll(async () => {
68
- // Cleanup: drop test objects
69
- const client = new Client({ connectionString: DATABASE_URL })
70
- await client.connect()
71
- try { await client.query(`DROP SUBSCRIPTION IF EXISTS ${SUB_NAME}`) } catch {}
72
- try { await client.query(`DROP MATERIALIZED VIEW IF EXISTS ${VIEW_NAME}`) } catch {}
73
- try { await client.query(`DROP TABLE IF EXISTS ${STREAM_NAME}`) } catch {}
74
- await client.end()
75
- await ddl.close()
76
- })
77
-
78
- it('creates tables, materialized views, and subscriptions', async () => {
79
- const actions = await ddl.sync(testConfig)
80
-
81
- const creates = actions.filter(a => a.type === 'create')
82
- expect(creates.length).toBeGreaterThanOrEqual(3) // event + query + subscription
83
-
84
- const eventAction = creates.find(a => a.resource === 'event' && a.name === STREAM_NAME)
85
- expect(eventAction).toBeDefined()
86
-
87
- const queryAction = creates.find(a => a.resource === 'query' && a.name === VIEW_NAME)
88
- expect(queryAction).toBeDefined()
89
-
90
- const subAction = creates.find(a => a.resource === 'subscription' && a.name === SUB_NAME)
91
- expect(subAction).toBeDefined()
92
- })
93
-
94
- it('is idempotent - second sync reports unchanged', async () => {
95
- const actions = await ddl.sync(testConfig)
96
-
97
- const unchanged = actions.filter(a => a.type === 'unchanged')
98
- expect(unchanged.length).toBeGreaterThanOrEqual(3)
99
-
100
- const creates = actions.filter(a => a.type === 'create')
101
- expect(creates.length).toBe(0)
102
- })
103
-
104
- it('can write and read data', async () => {
105
- const client = new Client({ connectionString: DATABASE_URL })
106
- await client.connect()
107
-
108
- await client.query(`INSERT INTO ${STREAM_NAME} (user_id, value) VALUES ('alice', 10)`)
109
- await client.query(`INSERT INTO ${STREAM_NAME} (user_id, value) VALUES ('alice', 20)`)
110
- await client.query(`INSERT INTO ${STREAM_NAME} (user_id, value) VALUES ('bob', 5)`)
111
-
112
- // Wait for MV to update
113
- await new Promise(r => setTimeout(r, 2000))
114
-
115
- const result = await client.query(`SELECT * FROM ${VIEW_NAME} ORDER BY total_value DESC`)
116
- expect(result.rows.length).toBeGreaterThanOrEqual(2)
117
-
118
- const alice = result.rows.find((r: any) => r.user_id === 'alice')
119
- expect(alice).toBeDefined()
120
- expect(Number(alice.total_value)).toBe(30)
121
- expect(Number(alice.event_count)).toBe(2)
122
-
123
- await client.end()
124
- })
125
- })
126
-
127
- describe.runIf(process.env.WAVELET_INTEGRATION === '1')('Integration: Full Server', () => {
128
- let server: WaveletServer
129
- let port: number
130
-
131
- beforeAll(async () => {
132
- // Ensure DDL is synced
133
- const ddl = new DdlManager(DATABASE_URL)
134
- await ddl.connect()
135
- await ddl.sync(testConfig)
136
- await ddl.close()
137
-
138
- // Start server on random port
139
- server = new WaveletServer({ ...testConfig, server: { port: 0 } })
140
- await server.start()
141
-
142
- // Get the actual port
143
- const addr = (server as any).httpServer?.address()
144
- port = addr?.port
145
- }, 15000)
146
-
147
- afterAll(async () => {
148
- await server.stop()
149
-
150
- // Cleanup
151
- const client = new Client({ connectionString: DATABASE_URL })
152
- await client.connect()
153
- try { await client.query(`DROP SUBSCRIPTION IF EXISTS ${SUB_NAME}`) } catch {}
154
- try { await client.query(`DROP MATERIALIZED VIEW IF EXISTS ${VIEW_NAME}`) } catch {}
155
- try { await client.query(`DROP TABLE IF EXISTS ${STREAM_NAME}`) } catch {}
156
- await client.end()
157
- })
158
-
159
- it('serves health check', async () => {
160
- const res = await fetch(`http://localhost:${port}/v1/health`)
161
- const data = await res.json()
162
- expect(data.status).toBe('ok')
163
- })
164
-
165
- it('lists queries', async () => {
166
- const res = await fetch(`http://localhost:${port}/v1/queries`)
167
- const data = await res.json()
168
- expect(data.queries).toContain(VIEW_NAME)
169
- })
170
-
171
- it('writes events via HTTP and reads query', async () => {
172
- // Write events
173
- await fetch(`http://localhost:${port}/v1/events/${STREAM_NAME}`, {
174
- method: 'POST',
175
- headers: { 'Content-Type': 'application/json' },
176
- body: JSON.stringify({ user_id: 'integration_test', value: 42 }),
177
- })
178
-
179
- // Wait for MV update
180
- await new Promise(r => setTimeout(r, 2000))
181
-
182
- // Read query
183
- const res = await fetch(`http://localhost:${port}/v1/queries/${VIEW_NAME}`)
184
- const data = await res.json()
185
- expect(data.rows.length).toBeGreaterThan(0)
186
- })
187
-
188
- it('pushes diffs via WebSocket', async () => {
189
- const diff = await new Promise<any>((resolve, reject) => {
190
- const ws = new WebSocket(`ws://localhost:${port}/subscribe/${VIEW_NAME}`)
191
- let sawSnapshot = false
192
-
193
- ws.on('message', (data: Buffer) => {
194
- const msg = JSON.parse(data.toString())
195
- if (msg.type === 'snapshot') {
196
- sawSnapshot = true
197
- // Write event to trigger diff
198
- fetch(`http://localhost:${port}/v1/events/${STREAM_NAME}`, {
199
- method: 'POST',
200
- headers: { 'Content-Type': 'application/json' },
201
- body: JSON.stringify({ user_id: 'ws_test_user', value: 7 }),
202
- })
203
- }
204
- if (msg.type === 'diff') {
205
- expect(sawSnapshot).toBe(true)
206
- ws.close()
207
- resolve(msg)
208
- }
209
- })
210
-
211
- ws.on('error', reject)
212
- setTimeout(() => { ws.close(); reject(new Error('WebSocket timeout')) }, 15000)
213
- })
214
-
215
- expect(diff.type).toBe('diff')
216
- expect(diff.cursor).toBeDefined()
217
- // Should have at least one insert or update
218
- expect(diff.inserted.length + diff.updated.length).toBeGreaterThan(0)
219
- }, 20000)
220
-
221
- it('pushes a snapshot immediately on connect', async () => {
222
- await fetch(`http://localhost:${port}/v1/events/${STREAM_NAME}`, {
223
- method: 'POST',
224
- headers: { 'Content-Type': 'application/json' },
225
- body: JSON.stringify({ user_id: 'snapshot_user', value: 11 }),
226
- })
227
-
228
- await new Promise(r => setTimeout(r, 2000))
229
-
230
- const snapshot = await new Promise<any>((resolve, reject) => {
231
- const ws = new WebSocket(`ws://localhost:${port}/subscribe/${VIEW_NAME}`)
232
-
233
- ws.on('message', (data: Buffer) => {
234
- const msg = JSON.parse(data.toString())
235
- if (msg.type === 'snapshot') {
236
- ws.close()
237
- resolve(msg)
238
- }
239
- })
240
-
241
- ws.on('error', reject)
242
- setTimeout(() => { ws.close(); reject(new Error('WebSocket snapshot timeout')) }, 15000)
243
- })
244
-
245
- expect(snapshot.type).toBe('snapshot')
246
- expect(Array.isArray(snapshot.rows)).toBe(true)
247
- expect(snapshot.rows.some((row: any) => row.user_id === 'snapshot_user')).toBe(true)
248
- }, 20000)
249
- })
@@ -1,62 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { JwtVerifier } from '../jwt.js'
3
- import * as jose from 'jose'
4
-
5
- describe('JwtVerifier', () => {
6
- it('isConfigured returns false with no config', () => {
7
- const v = new JwtVerifier()
8
- expect(v.isConfigured()).toBe(false)
9
- })
10
-
11
- it('isConfigured returns true with secret', () => {
12
- const v = new JwtVerifier({ secret: 'test-secret' })
13
- expect(v.isConfigured()).toBe(true)
14
- })
15
-
16
- it('isConfigured returns true with jwksUrl', () => {
17
- const v = new JwtVerifier({ jwksUrl: 'https://example.com/.well-known/jwks.json' })
18
- expect(v.isConfigured()).toBe(true)
19
- })
20
-
21
- it('verifies a valid HS256 JWT', async () => {
22
- const secret = 'my-test-secret-that-is-long-enough'
23
- const v = new JwtVerifier({ secret })
24
-
25
- const encodedSecret = new TextEncoder().encode(secret)
26
- const token = await new jose.SignJWT({ tenant_id: 't1', role: 'user' })
27
- .setProtectedHeader({ alg: 'HS256' })
28
- .setIssuedAt()
29
- .setExpirationTime('1h')
30
- .sign(encodedSecret)
31
-
32
- const claims = await v.verify(token)
33
- expect(claims.tenant_id).toBe('t1')
34
- expect(claims.role).toBe('user')
35
- })
36
-
37
- it('rejects expired tokens', async () => {
38
- const secret = 'my-test-secret-that-is-long-enough'
39
- const v = new JwtVerifier({ secret })
40
-
41
- const encodedSecret = new TextEncoder().encode(secret)
42
- const token = await new jose.SignJWT({ tenant_id: 't1' })
43
- .setProtectedHeader({ alg: 'HS256' })
44
- .setIssuedAt(Math.floor(Date.now() / 1000) - 7200)
45
- .setExpirationTime(Math.floor(Date.now() / 1000) - 3600)
46
- .sign(encodedSecret)
47
-
48
- await expect(v.verify(token)).rejects.toThrow('expired')
49
- })
50
-
51
- it('rejects tokens with wrong secret', async () => {
52
- const v = new JwtVerifier({ secret: 'correct-secret-that-is-long-enough' })
53
-
54
- const wrongSecret = new TextEncoder().encode('wrong-secret-that-is-long-enough')
55
- const token = await new jose.SignJWT({ tenant_id: 't1' })
56
- .setProtectedHeader({ alg: 'HS256' })
57
- .setExpirationTime('1h')
58
- .sign(wrongSecret)
59
-
60
- await expect(v.verify(token)).rejects.toThrow()
61
- })
62
- })
@@ -1,143 +0,0 @@
1
- import { EventEmitter } from 'node:events'
2
- import { describe, it, expect, vi } from 'vitest'
3
- import { WebSocket } from 'ws'
4
- import { WebSocketFanout } from '../ws-fanout.js'
5
- import type { BootstrapResult, ViewDiff } from '../cursor-manager.js'
6
-
7
- class MockSocket extends EventEmitter {
8
- readyState: number = WebSocket.OPEN
9
- sent: string[] = []
10
-
11
- send(message: string): void {
12
- this.sent.push(message)
13
- }
14
-
15
- ping(): void {}
16
-
17
- close(): void {
18
- this.readyState = WebSocket.CLOSED
19
- this.emit('close')
20
- }
21
- }
22
-
23
- function deferred<T>() {
24
- let resolve!: (value: T) => void
25
- let reject!: (reason?: unknown) => void
26
- const promise = new Promise<T>((res, rej) => {
27
- resolve = res
28
- reject = rej
29
- })
30
- return { promise, resolve, reject }
31
- }
32
-
33
- describe('WebSocketFanout', () => {
34
- it('drops queued shared diffs that are already covered by bootstrap', async () => {
35
- const bootstrapDeferred = deferred<BootstrapResult>()
36
- const cursorManager = {
37
- bootstrap: vi.fn().mockReturnValue(bootstrapDeferred.promise),
38
- } as any
39
- const jwt = {
40
- isConfigured: () => false,
41
- } as any
42
-
43
- const fanout = new WebSocketFanout(cursorManager, jwt, {
44
- leaderboard: {} as any,
45
- })
46
-
47
- const ws = new MockSocket()
48
- const req = {
49
- url: '/subscribe/leaderboard',
50
- headers: { host: 'localhost' },
51
- } as any
52
-
53
- const connectionPromise = (fanout as any).handleConnection(ws, req)
54
-
55
- fanout.broadcast('leaderboard', {
56
- cursor: '150',
57
- inserted: [{ player_id: 'alice', score: 10 }],
58
- updated: [],
59
- deleted: [],
60
- })
61
- fanout.broadcast('leaderboard', {
62
- cursor: '300',
63
- inserted: [{ player_id: 'bob', score: 20 }],
64
- updated: [],
65
- deleted: [],
66
- })
67
-
68
- bootstrapDeferred.resolve({
69
- snapshotRows: [{ player_id: 'alice', score: 10 }],
70
- diffs: [{
71
- cursor: '200',
72
- inserted: [],
73
- updated: [{ player_id: 'alice', score: 15 }],
74
- deleted: [],
75
- }],
76
- lastCursor: '200',
77
- })
78
-
79
- await connectionPromise
80
-
81
- const messages = ws.sent.map((message) => JSON.parse(message))
82
- expect(messages).toEqual([
83
- { type: 'connected', query: 'leaderboard' },
84
- {
85
- type: 'snapshot',
86
- query: 'leaderboard',
87
- rows: [{ player_id: 'alice', score: 10 }],
88
- },
89
- {
90
- type: 'diff',
91
- query: 'leaderboard',
92
- cursor: '200',
93
- inserted: [],
94
- updated: [{ player_id: 'alice', score: 15 }],
95
- deleted: [],
96
- },
97
- {
98
- type: 'diff',
99
- query: 'leaderboard',
100
- cursor: '300',
101
- inserted: [{ player_id: 'bob', score: 20 }],
102
- updated: [],
103
- deleted: [],
104
- },
105
- ])
106
- })
107
-
108
- it('filters snapshot rows with the same claim rule as diffs', async () => {
109
- const cursorManager = {
110
- bootstrap: vi.fn().mockResolvedValue({
111
- snapshotRows: [
112
- { user_id: 'u1', total: 10 },
113
- { user_id: 'u2', total: 20 },
114
- ],
115
- diffs: [] as ViewDiff[],
116
- lastCursor: null,
117
- }),
118
- } as any
119
- const jwt = {
120
- isConfigured: () => true,
121
- verify: vi.fn().mockResolvedValue({ user_id: 'u1' }),
122
- } as any
123
-
124
- const fanout = new WebSocketFanout(cursorManager, jwt, {
125
- totals: { filterBy: 'user_id' } as any,
126
- })
127
-
128
- const ws = new MockSocket()
129
- const req = {
130
- url: '/subscribe/totals?token=test-token',
131
- headers: { host: 'localhost' },
132
- } as any
133
-
134
- await (fanout as any).handleConnection(ws, req)
135
-
136
- const snapshotMessage = JSON.parse(ws.sent[1])
137
- expect(snapshotMessage).toEqual({
138
- type: 'snapshot',
139
- query: 'totals',
140
- rows: [{ user_id: 'u1', total: 10 }],
141
- })
142
- })
143
- })