@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 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
+ }