@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.
- package/dist/__tests__/cursor-parsing.test.d.ts +2 -0
- package/dist/__tests__/cursor-parsing.test.d.ts.map +1 -0
- package/dist/__tests__/cursor-parsing.test.js +64 -0
- package/dist/__tests__/cursor-parsing.test.js.map +1 -0
- package/dist/__tests__/http-api.test.d.ts +2 -0
- package/dist/__tests__/http-api.test.d.ts.map +1 -0
- package/dist/__tests__/http-api.test.js +135 -0
- package/dist/__tests__/http-api.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +229 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/jwt.test.d.ts +2 -0
- package/dist/__tests__/jwt.test.d.ts.map +1 -0
- package/dist/__tests__/jwt.test.js +86 -0
- package/dist/__tests__/jwt.test.js.map +1 -0
- package/dist/__tests__/ws-fanout.test.d.ts +2 -0
- package/dist/__tests__/ws-fanout.test.d.ts.map +1 -0
- package/dist/__tests__/ws-fanout.test.js +127 -0
- package/dist/__tests__/ws-fanout.test.js.map +1 -0
- package/dist/config-loader.d.ts +3 -0
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/config-loader.js +25 -0
- package/dist/config-loader.js.map +1 -0
- package/dist/cursor-manager.d.ts +54 -0
- package/dist/cursor-manager.d.ts.map +1 -0
- package/dist/cursor-manager.js +263 -0
- package/dist/cursor-manager.js.map +1 -0
- package/dist/ddl-manager.d.ts +33 -0
- package/dist/ddl-manager.d.ts.map +1 -0
- package/dist/ddl-manager.js +364 -0
- package/dist/ddl-manager.js.map +1 -0
- package/dist/http-api.d.ts +21 -0
- package/dist/http-api.d.ts.map +1 -0
- package/dist/http-api.js +242 -0
- package/dist/http-api.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +16 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +87 -0
- package/dist/jwt.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +82 -0
- package/dist/server.js.map +1 -0
- package/dist/webhook.d.ts +9 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +63 -0
- package/dist/webhook.js.map +1 -0
- package/dist/ws-fanout.d.ts +24 -0
- package/dist/ws-fanout.d.ts.map +1 -0
- package/dist/ws-fanout.js +198 -0
- package/dist/ws-fanout.js.map +1 -0
- package/package.json +7 -3
- package/src/__tests__/cursor-parsing.test.ts +0 -68
- package/src/__tests__/http-api.test.ts +0 -130
- package/src/__tests__/integration.test.ts +0 -249
- package/src/__tests__/jwt.test.ts +0 -62
- package/src/__tests__/ws-fanout.test.ts +0 -143
- package/src/config-loader.ts +0 -28
- package/src/cursor-manager.ts +0 -311
- package/src/ddl-manager.ts +0 -408
- package/src/http-api.ts +0 -278
- package/src/index.ts +0 -31
- package/src/jwt.ts +0 -56
- package/src/server.ts +0 -89
- package/src/webhook.ts +0 -67
- package/src/ws-fanout.ts +0 -245
- 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.
|
|
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.
|
|
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
|
-
})
|