@risingwave/wavelet-server 0.1.0
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/package.json +25 -0
- package/src/__tests__/cursor-parsing.test.ts +68 -0
- package/src/__tests__/http-api.test.ts +130 -0
- package/src/__tests__/integration.test.ts +217 -0
- package/src/__tests__/jwt.test.ts +62 -0
- package/src/config-loader.ts +20 -0
- package/src/cursor-manager.ts +209 -0
- package/src/ddl-manager.ts +307 -0
- package/src/http-api.ts +199 -0
- package/src/index.ts +31 -0
- package/src/jwt.ts +56 -0
- package/src/server.ts +82 -0
- package/src/ws-fanout.ts +159 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@risingwave/wavelet-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Wavelet server - WebSocket fanout layer for RisingWave",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsx watch src/index.ts",
|
|
10
|
+
"start": "node dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"pg": "^8.13.0",
|
|
14
|
+
"ws": "^8.18.0",
|
|
15
|
+
"jose": "^6.0.0",
|
|
16
|
+
"@risingwave/wavelet": "0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/pg": "^8.11.0",
|
|
20
|
+
"@types/ws": "^8.5.0",
|
|
21
|
+
"tsx": "^4.0.0",
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
},
|
|
24
|
+
"license": "Apache-2.0"
|
|
25
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
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 streams = {
|
|
40
|
+
events: { columns: { user_id: 'string' as const, value: 'int' as const } },
|
|
41
|
+
}
|
|
42
|
+
const views = {
|
|
43
|
+
leaderboard: { _tag: 'sql' as const, text: 'SELECT 1' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('returns health check', async () => {
|
|
47
|
+
const api = new HttpApi('postgres://dummy', streams, views)
|
|
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 views', async () => {
|
|
59
|
+
const api = new HttpApi('postgres://dummy', streams, views)
|
|
60
|
+
const server = await createTestServer(api)
|
|
61
|
+
try {
|
|
62
|
+
const res = await request(server, 'GET', '/v1/views')
|
|
63
|
+
expect(res.status).toBe(200)
|
|
64
|
+
expect(res.data).toEqual({ views: ['leaderboard'] })
|
|
65
|
+
} finally {
|
|
66
|
+
server.close()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('lists available streams', async () => {
|
|
71
|
+
const api = new HttpApi('postgres://dummy', streams, views)
|
|
72
|
+
const server = await createTestServer(api)
|
|
73
|
+
try {
|
|
74
|
+
const res = await request(server, 'GET', '/v1/streams')
|
|
75
|
+
expect(res.status).toBe(200)
|
|
76
|
+
expect(res.data).toEqual({ streams: ['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', streams, views)
|
|
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', streams, views)
|
|
98
|
+
const server = await createTestServer(api)
|
|
99
|
+
try {
|
|
100
|
+
const res = await request(server, 'OPTIONS', '/v1/views')
|
|
101
|
+
expect(res.status).toBe(204)
|
|
102
|
+
} finally {
|
|
103
|
+
server.close()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns 404 for unknown stream', async () => {
|
|
108
|
+
const api = new HttpApi('postgres://dummy', streams, views)
|
|
109
|
+
const server = await createTestServer(api)
|
|
110
|
+
try {
|
|
111
|
+
const res = await request(server, 'POST', '/v1/streams/nonexistent', { x: 1 })
|
|
112
|
+
expect(res.status).toBe(404)
|
|
113
|
+
expect(res.data.available_streams).toEqual(['events'])
|
|
114
|
+
} finally {
|
|
115
|
+
server.close()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('returns 404 for unknown view', async () => {
|
|
120
|
+
const api = new HttpApi('postgres://dummy', streams, views)
|
|
121
|
+
const server = await createTestServer(api)
|
|
122
|
+
try {
|
|
123
|
+
const res = await request(server, 'GET', '/v1/views/nonexistent')
|
|
124
|
+
expect(res.status).toBe(404)
|
|
125
|
+
expect(res.data.available_views).toEqual(['leaderboard'])
|
|
126
|
+
} finally {
|
|
127
|
+
server.close()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
streams: {
|
|
40
|
+
[STREAM_NAME]: {
|
|
41
|
+
columns: {
|
|
42
|
+
user_id: 'string',
|
|
43
|
+
value: 'int',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
views: {
|
|
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, 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) // stream + view + subscription
|
|
83
|
+
|
|
84
|
+
const streamAction = creates.find(a => a.resource === 'stream' && a.name === STREAM_NAME)
|
|
85
|
+
expect(streamAction).toBeDefined()
|
|
86
|
+
|
|
87
|
+
const viewAction = creates.find(a => a.resource === 'view' && a.name === VIEW_NAME)
|
|
88
|
+
expect(viewAction).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 views', async () => {
|
|
166
|
+
const res = await fetch(`http://localhost:${port}/v1/views`)
|
|
167
|
+
const data = await res.json()
|
|
168
|
+
expect(data.views).toContain(VIEW_NAME)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('writes events via HTTP and reads view', async () => {
|
|
172
|
+
// Write events
|
|
173
|
+
await fetch(`http://localhost:${port}/v1/streams/${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 view
|
|
183
|
+
const res = await fetch(`http://localhost:${port}/v1/views/${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
|
+
|
|
192
|
+
ws.on('message', (data: Buffer) => {
|
|
193
|
+
const msg = JSON.parse(data.toString())
|
|
194
|
+
if (msg.type === 'connected') {
|
|
195
|
+
// Write event to trigger diff
|
|
196
|
+
fetch(`http://localhost:${port}/v1/streams/${STREAM_NAME}`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ user_id: 'ws_test_user', value: 7 }),
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
if (msg.type === 'diff') {
|
|
203
|
+
ws.close()
|
|
204
|
+
resolve(msg)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
ws.on('error', reject)
|
|
209
|
+
setTimeout(() => { ws.close(); reject(new Error('WebSocket timeout')) }, 15000)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(diff.type).toBe('diff')
|
|
213
|
+
expect(diff.cursor).toBeDefined()
|
|
214
|
+
// Should have at least one insert or update
|
|
215
|
+
expect(diff.inserted.length + diff.updated.length).toBeGreaterThan(0)
|
|
216
|
+
}, 20000)
|
|
217
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { WaveletConfig } from '@risingwave/wavelet'
|
|
4
|
+
|
|
5
|
+
export async function loadConfig(configPath: string): Promise<WaveletConfig> {
|
|
6
|
+
const abs = resolve(configPath)
|
|
7
|
+
const mod = await import(pathToFileURL(abs).href)
|
|
8
|
+
const config = mod.default ?? mod
|
|
9
|
+
|
|
10
|
+
if (!config.database) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`wavelet.config.ts must export a config with a 'database' field.\n` +
|
|
13
|
+
`Example:\n` +
|
|
14
|
+
` import { defineConfig } from '@risingwave/wavelet'\n` +
|
|
15
|
+
` export default defineConfig({ database: 'postgres://...' })`
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return config
|
|
20
|
+
}
|