@risingwave/wavelet-server 0.1.4 → 0.2.4
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 +11 -2
- package/src/__tests__/http-api.test.ts +23 -23
- package/src/__tests__/integration.test.ts +49 -17
- package/src/__tests__/ws-fanout.test.ts +143 -0
- package/src/cursor-manager.ts +133 -31
- package/src/ddl-manager.ts +72 -71
- package/src/http-api.ts +150 -71
- package/src/server.ts +15 -8
- package/src/webhook.ts +67 -0
- package/src/ws-fanout.ts +128 -42
package/src/ddl-manager.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import pg from 'pg'
|
|
2
|
-
import type { WaveletConfig,
|
|
2
|
+
import type { WaveletConfig, EventDef, QueryDef, SqlFragment, PostgresCdcSource } from '@risingwave/wavelet'
|
|
3
3
|
|
|
4
4
|
const { Client } = pg
|
|
5
5
|
|
|
6
6
|
export interface DdlAction {
|
|
7
7
|
type: 'create' | 'update' | 'delete' | 'unchanged'
|
|
8
|
-
resource: '
|
|
8
|
+
resource: 'event' | 'source' | 'query' | 'subscription'
|
|
9
9
|
name: string
|
|
10
10
|
detail?: string
|
|
11
11
|
}
|
|
@@ -26,13 +26,13 @@ function normalizeSql(sql: string): string {
|
|
|
26
26
|
return stripped.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function
|
|
30
|
-
if ('_tag' in
|
|
31
|
-
return (
|
|
29
|
+
function getQuerySql(queryDef: QueryDef | SqlFragment): string {
|
|
30
|
+
if ('_tag' in queryDef && queryDef._tag === 'sql') return queryDef.text
|
|
31
|
+
return (queryDef as QueryDef).query.text
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function buildCreateTableSql(name: string,
|
|
35
|
-
const cols = Object.entries(
|
|
34
|
+
function buildCreateTableSql(name: string, eventDef: EventDef): string {
|
|
35
|
+
const cols = Object.entries(eventDef.columns)
|
|
36
36
|
.map(([colName, colType]) => {
|
|
37
37
|
const sqlType = COLUMN_TYPE_MAP[colType]
|
|
38
38
|
if (!sqlType) throw new Error(`Unknown column type "${colType}" for column "${colName}"`)
|
|
@@ -60,7 +60,7 @@ export class DdlManager {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Sync all
|
|
63
|
+
* Sync all events, queries, and subscriptions to match the config.
|
|
64
64
|
* Returns a list of actions taken.
|
|
65
65
|
* Idempotent - safe to call multiple times.
|
|
66
66
|
*/
|
|
@@ -71,24 +71,24 @@ export class DdlManager {
|
|
|
71
71
|
|
|
72
72
|
// 1. Fetch existing state from RisingWave
|
|
73
73
|
const existingTables = await this.getExistingTables()
|
|
74
|
-
const
|
|
74
|
+
const existingMVs = await this.getExistingMaterializedViews()
|
|
75
75
|
const existingSubscriptions = await this.getExistingSubscriptions()
|
|
76
76
|
|
|
77
|
-
const
|
|
77
|
+
const desiredEvents = config.events ?? config.streams ?? {}
|
|
78
78
|
const desiredSources = config.sources ?? {}
|
|
79
|
-
const
|
|
79
|
+
const desiredQueries = config.queries ?? config.views ?? {}
|
|
80
80
|
|
|
81
|
-
// 2. Determine which
|
|
82
|
-
const
|
|
83
|
-
const
|
|
81
|
+
// 2. Determine which events (tables) to create or remove
|
|
82
|
+
const desiredEventNames = new Set(Object.keys(desiredEvents))
|
|
83
|
+
const desiredQueryNames = new Set(Object.keys(desiredQueries))
|
|
84
84
|
|
|
85
|
-
// 3. Sync
|
|
86
|
-
for (const [
|
|
87
|
-
if (existingTables.has(
|
|
88
|
-
actions.push({ type: 'unchanged', resource: '
|
|
85
|
+
// 3. Sync events - create missing tables
|
|
86
|
+
for (const [eventName, eventDef] of Object.entries(desiredEvents)) {
|
|
87
|
+
if (existingTables.has(eventName)) {
|
|
88
|
+
actions.push({ type: 'unchanged', resource: 'event', name: eventName })
|
|
89
89
|
} else {
|
|
90
|
-
await this.createTable(
|
|
91
|
-
actions.push({ type: 'create', resource: '
|
|
90
|
+
await this.createTable(eventName, eventDef)
|
|
91
|
+
actions.push({ type: 'create', resource: 'event', name: eventName })
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -108,41 +108,41 @@ export class DdlManager {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
// 4. Sync
|
|
112
|
-
for (const [
|
|
113
|
-
const subName = `wavelet_sub_${
|
|
114
|
-
const desiredSql =
|
|
115
|
-
const existingSql =
|
|
111
|
+
// 4. Sync queries - create, update, or leave unchanged
|
|
112
|
+
for (const [queryName, queryDef] of Object.entries(desiredQueries)) {
|
|
113
|
+
const subName = `wavelet_sub_${queryName}`
|
|
114
|
+
const desiredSql = getQuerySql(queryDef)
|
|
115
|
+
const existingSql = existingMVs.get(queryName)
|
|
116
116
|
|
|
117
117
|
if (existingSql === undefined) {
|
|
118
|
-
//
|
|
119
|
-
await this.createMaterializedView(
|
|
120
|
-
actions.push({ type: 'create', resource: '
|
|
118
|
+
// MV does not exist - create MV and subscription
|
|
119
|
+
await this.createMaterializedView(queryName, desiredSql)
|
|
120
|
+
actions.push({ type: 'create', resource: 'query', name: queryName })
|
|
121
121
|
|
|
122
|
-
await this.createSubscription(subName,
|
|
122
|
+
await this.createSubscription(subName, queryName)
|
|
123
123
|
actions.push({ type: 'create', resource: 'subscription', name: subName })
|
|
124
124
|
} else if (normalizeSql(existingSql) !== normalizeSql(desiredSql)) {
|
|
125
|
-
//
|
|
125
|
+
// Query SQL changed - drop subscription, drop MV, recreate
|
|
126
126
|
if (existingSubscriptions.has(subName)) {
|
|
127
127
|
await this.dropSubscription(subName)
|
|
128
|
-
actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'dropped for
|
|
128
|
+
actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'dropped for query update' })
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
await this.dropMaterializedView(
|
|
132
|
-
actions.push({ type: 'delete', resource: '
|
|
131
|
+
await this.dropMaterializedView(queryName)
|
|
132
|
+
actions.push({ type: 'delete', resource: 'query', name: queryName, detail: 'dropped for update' })
|
|
133
133
|
|
|
134
|
-
await this.createMaterializedView(
|
|
135
|
-
actions.push({ type: 'create', resource: '
|
|
134
|
+
await this.createMaterializedView(queryName, desiredSql)
|
|
135
|
+
actions.push({ type: 'create', resource: 'query', name: queryName, detail: 'recreated with updated SQL' })
|
|
136
136
|
|
|
137
|
-
await this.createSubscription(subName,
|
|
138
|
-
actions.push({ type: 'create', resource: 'subscription', name: subName, detail: 'recreated after
|
|
137
|
+
await this.createSubscription(subName, queryName)
|
|
138
|
+
actions.push({ type: 'create', resource: 'subscription', name: subName, detail: 'recreated after query update' })
|
|
139
139
|
} else {
|
|
140
|
-
//
|
|
141
|
-
actions.push({ type: 'unchanged', resource: '
|
|
140
|
+
// Query SQL unchanged
|
|
141
|
+
actions.push({ type: 'unchanged', resource: 'query', name: queryName })
|
|
142
142
|
|
|
143
|
-
// Ensure subscription exists even if the
|
|
143
|
+
// Ensure subscription exists even if the query is unchanged
|
|
144
144
|
if (!existingSubscriptions.has(subName)) {
|
|
145
|
-
await this.createSubscription(subName,
|
|
145
|
+
await this.createSubscription(subName, queryName)
|
|
146
146
|
actions.push({ type: 'create', resource: 'subscription', name: subName })
|
|
147
147
|
} else {
|
|
148
148
|
actions.push({ type: 'unchanged', resource: 'subscription', name: subName })
|
|
@@ -150,19 +150,19 @@ export class DdlManager {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// 5. Remove
|
|
154
|
-
for (const [
|
|
155
|
-
if (!
|
|
156
|
-
const subName = `wavelet_sub_${
|
|
153
|
+
// 5. Remove queries that are no longer in the config
|
|
154
|
+
for (const [existingMVName] of existingMVs) {
|
|
155
|
+
if (!desiredQueryNames.has(existingMVName)) {
|
|
156
|
+
const subName = `wavelet_sub_${existingMVName}`
|
|
157
157
|
|
|
158
158
|
// Drop subscription first
|
|
159
159
|
if (existingSubscriptions.has(subName)) {
|
|
160
160
|
await this.dropSubscription(subName)
|
|
161
|
-
actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: '
|
|
161
|
+
actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'query removed from config' })
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
await this.dropMaterializedView(
|
|
165
|
-
actions.push({ type: 'delete', resource: '
|
|
164
|
+
await this.dropMaterializedView(existingMVName)
|
|
165
|
+
actions.push({ type: 'delete', resource: 'query', name: existingMVName, detail: 'removed from config' })
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -171,32 +171,32 @@ export class DdlManager {
|
|
|
171
171
|
// Only manage wavelet-prefixed subscriptions
|
|
172
172
|
if (!existingSubName.startsWith('wavelet_sub_')) continue
|
|
173
173
|
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
176
|
-
// Already handled in step 5 if the
|
|
177
|
-
if (!
|
|
174
|
+
const queryName = existingSubName.slice('wavelet_sub_'.length)
|
|
175
|
+
if (!desiredQueryNames.has(queryName)) {
|
|
176
|
+
// Already handled in step 5 if the MV existed, but handle dangling subs too
|
|
177
|
+
if (!existingMVs.has(queryName)) {
|
|
178
178
|
await this.dropSubscription(existingSubName)
|
|
179
179
|
actions.push({ type: 'delete', resource: 'subscription', name: existingSubName, detail: 'orphaned subscription' })
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// 7. Remove
|
|
184
|
+
// 7. Remove events (tables) that are no longer in the config
|
|
185
185
|
for (const existingTableName of existingTables) {
|
|
186
|
-
if (!
|
|
186
|
+
if (!desiredEventNames.has(existingTableName)) {
|
|
187
187
|
// Only drop if no MV depends on it
|
|
188
|
-
const hasDependents = await this.
|
|
188
|
+
const hasDependents = await this.tableHasDependentMVs(existingTableName)
|
|
189
189
|
if (hasDependents) {
|
|
190
190
|
console.log(`[ddl-manager] Skipping drop of table "${existingTableName}" - materialized views depend on it`)
|
|
191
191
|
actions.push({
|
|
192
192
|
type: 'unchanged',
|
|
193
|
-
resource: '
|
|
193
|
+
resource: 'event',
|
|
194
194
|
name: existingTableName,
|
|
195
|
-
detail: 'kept because dependent views exist',
|
|
195
|
+
detail: 'kept because dependent materialized views exist',
|
|
196
196
|
})
|
|
197
197
|
} else {
|
|
198
198
|
await this.dropTable(existingTableName)
|
|
199
|
-
actions.push({ type: 'delete', resource: '
|
|
199
|
+
actions.push({ type: 'delete', resource: 'event', name: existingTableName, detail: 'removed from config' })
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
}
|
|
@@ -213,7 +213,7 @@ export class DdlManager {
|
|
|
213
213
|
return new Set(result.rows.map((r: any) => r.table_name))
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
private async
|
|
216
|
+
private async getExistingMaterializedViews(): Promise<Map<string, string>> {
|
|
217
217
|
const result = await this.client!.query(
|
|
218
218
|
`SELECT name, definition FROM rw_catalog.rw_materialized_views WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public')`
|
|
219
219
|
)
|
|
@@ -243,7 +243,7 @@ export class DdlManager {
|
|
|
243
243
|
return new Set(result.rows.map((r: any) => r.name))
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
private async
|
|
246
|
+
private async tableHasDependentMVs(tableName: string): Promise<boolean> {
|
|
247
247
|
const result = await this.client!.query(
|
|
248
248
|
`SELECT name FROM rw_catalog.rw_materialized_views WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public') AND definition ILIKE $1`,
|
|
249
249
|
[`%${tableName}%`]
|
|
@@ -253,8 +253,8 @@ export class DdlManager {
|
|
|
253
253
|
|
|
254
254
|
// ── DDL operations ────────────────────────────────────────────────────
|
|
255
255
|
|
|
256
|
-
private async createTable(name: string,
|
|
257
|
-
const sql = buildCreateTableSql(name,
|
|
256
|
+
private async createTable(name: string, eventDef: EventDef): Promise<void> {
|
|
257
|
+
const sql = buildCreateTableSql(name, eventDef)
|
|
258
258
|
try {
|
|
259
259
|
await this.client!.query(sql)
|
|
260
260
|
console.log(`[ddl-manager] Created table: ${name}`)
|
|
@@ -359,20 +359,21 @@ export class DdlManager {
|
|
|
359
359
|
|
|
360
360
|
const parsed = parsePostgresUrl(source.connection)
|
|
361
361
|
|
|
362
|
+
const esc = (s: string) => s.replace(/'/g, "''")
|
|
362
363
|
try {
|
|
363
364
|
await this.client!.query(`
|
|
364
365
|
CREATE TABLE IF NOT EXISTS ${cdcTableName} (*)
|
|
365
366
|
WITH (
|
|
366
367
|
connector = 'postgres-cdc',
|
|
367
|
-
hostname = '${parsed.host}',
|
|
368
|
-
port = '${parsed.port}',
|
|
369
|
-
username = '${parsed.user}',
|
|
370
|
-
password = '${parsed.password}',
|
|
371
|
-
database.name = '${parsed.database}',
|
|
372
|
-
schema.name = '${parsed.schema}',
|
|
373
|
-
table.name = '${tableName}',
|
|
374
|
-
slot.name = '${slotName}',
|
|
375
|
-
publication.name = '${pubName}'
|
|
368
|
+
hostname = '${esc(parsed.host)}',
|
|
369
|
+
port = '${esc(parsed.port)}',
|
|
370
|
+
username = '${esc(parsed.user)}',
|
|
371
|
+
password = '${esc(parsed.password)}',
|
|
372
|
+
database.name = '${esc(parsed.database)}',
|
|
373
|
+
schema.name = '${esc(parsed.schema)}',
|
|
374
|
+
table.name = '${esc(tableName)}',
|
|
375
|
+
slot.name = '${esc(slotName)}',
|
|
376
|
+
publication.name = '${esc(pubName)}'
|
|
376
377
|
)
|
|
377
378
|
`)
|
|
378
379
|
console.log(`[ddl-manager] Created CDC source: ${cdcTableName} (from ${parsed.host}/${parsed.database}.${tableName})`)
|
package/src/http-api.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
2
|
import pg from 'pg'
|
|
3
|
-
import type {
|
|
3
|
+
import type { EventDef, QueryDef, SqlFragment } from '@risingwave/wavelet'
|
|
4
|
+
import type { JwtVerifier, JwtClaims } from './jwt.js'
|
|
4
5
|
|
|
5
|
-
const {
|
|
6
|
+
const { Pool } = pg
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
|
6
9
|
|
|
7
10
|
export class HttpApi {
|
|
8
|
-
private
|
|
11
|
+
private pool: InstanceType<typeof Pool> | null = null
|
|
9
12
|
|
|
10
13
|
constructor(
|
|
11
14
|
private connectionString: string,
|
|
12
|
-
private
|
|
13
|
-
private
|
|
15
|
+
private events: Record<string, EventDef>,
|
|
16
|
+
private queries: Record<string, QueryDef | SqlFragment>,
|
|
17
|
+
private jwt?: JwtVerifier
|
|
14
18
|
) {}
|
|
15
19
|
|
|
16
20
|
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
@@ -28,24 +32,24 @@ export class HttpApi {
|
|
|
28
32
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
29
33
|
|
|
30
34
|
try {
|
|
31
|
-
// POST /v1/
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
await this.handleWrite(
|
|
35
|
+
// POST /v1/events/{name}
|
|
36
|
+
const eventMatch = url.pathname.match(/^\/v1\/events\/([^/]+)$/)
|
|
37
|
+
if (eventMatch && req.method === 'POST') {
|
|
38
|
+
await this.handleWrite(eventMatch[1], req, res)
|
|
35
39
|
return
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
// POST /v1/
|
|
39
|
-
const batchMatch = url.pathname.match(/^\/v1\/
|
|
42
|
+
// POST /v1/events/{name}/batch
|
|
43
|
+
const batchMatch = url.pathname.match(/^\/v1\/events\/([^/]+)\/batch$/)
|
|
40
44
|
if (batchMatch && req.method === 'POST') {
|
|
41
45
|
await this.handleBatchWrite(batchMatch[1], req, res)
|
|
42
46
|
return
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
// GET /v1/
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
await this.handleRead(
|
|
49
|
+
// GET /v1/queries/{name}
|
|
50
|
+
const queryMatch = url.pathname.match(/^\/v1\/queries\/([^/]+)$/)
|
|
51
|
+
if (queryMatch && req.method === 'GET') {
|
|
52
|
+
await this.handleRead(queryMatch[1], url, req, res)
|
|
49
53
|
return
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -55,15 +59,15 @@ export class HttpApi {
|
|
|
55
59
|
return
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
// GET /v1/
|
|
59
|
-
if (url.pathname === '/v1/
|
|
60
|
-
this.json(res, 200, {
|
|
62
|
+
// GET /v1/queries - list available queries
|
|
63
|
+
if (url.pathname === '/v1/queries' && req.method === 'GET') {
|
|
64
|
+
this.json(res, 200, { queries: Object.keys(this.queries) })
|
|
61
65
|
return
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
// GET /v1/
|
|
65
|
-
if (url.pathname === '/v1/
|
|
66
|
-
this.json(res, 200, {
|
|
68
|
+
// GET /v1/events - list available events
|
|
69
|
+
if (url.pathname === '/v1/events' && req.method === 'GET') {
|
|
70
|
+
this.json(res, 200, { events: Object.keys(this.events) })
|
|
67
71
|
return
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -72,11 +76,11 @@ export class HttpApi {
|
|
|
72
76
|
message: `${req.method} ${url.pathname} does not match any route.`,
|
|
73
77
|
routes: [
|
|
74
78
|
'GET /v1/health',
|
|
75
|
-
'GET /v1/
|
|
76
|
-
'GET /v1/
|
|
77
|
-
'GET /v1/
|
|
78
|
-
'POST /v1/
|
|
79
|
-
'POST /v1/
|
|
79
|
+
'GET /v1/queries',
|
|
80
|
+
'GET /v1/queries/{name}',
|
|
81
|
+
'GET /v1/events',
|
|
82
|
+
'POST /v1/events/{name}',
|
|
83
|
+
'POST /v1/events/{name}/batch',
|
|
80
84
|
],
|
|
81
85
|
})
|
|
82
86
|
} catch (err: any) {
|
|
@@ -85,21 +89,20 @@ export class HttpApi {
|
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
private
|
|
89
|
-
if (!this.
|
|
90
|
-
this.
|
|
91
|
-
await this.client.connect()
|
|
92
|
+
private ensurePool(): InstanceType<typeof Pool> {
|
|
93
|
+
if (!this.pool) {
|
|
94
|
+
this.pool = new Pool({ connectionString: this.connectionString, max: 10 })
|
|
92
95
|
}
|
|
93
|
-
return this.
|
|
96
|
+
return this.pool
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
private async handleWrite(
|
|
97
|
-
const
|
|
98
|
-
if (!
|
|
99
|
-
const available = Object.keys(this.
|
|
99
|
+
private async handleWrite(eventName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
100
|
+
const eventDef = this.events[eventName]
|
|
101
|
+
if (!eventDef) {
|
|
102
|
+
const available = Object.keys(this.events)
|
|
100
103
|
this.json(res, 404, {
|
|
101
|
-
error: `
|
|
102
|
-
|
|
104
|
+
error: `Event '${eventName}' not found.`,
|
|
105
|
+
available_events: available,
|
|
103
106
|
})
|
|
104
107
|
return
|
|
105
108
|
}
|
|
@@ -107,80 +110,147 @@ export class HttpApi {
|
|
|
107
110
|
const body = await this.readBody(req)
|
|
108
111
|
const data = JSON.parse(body)
|
|
109
112
|
|
|
110
|
-
const
|
|
111
|
-
const columns = Object.keys(
|
|
113
|
+
const pool = this.ensurePool()
|
|
114
|
+
const columns = Object.keys(eventDef.columns)
|
|
112
115
|
const values = columns.map((col) => data[col])
|
|
113
116
|
const placeholders = columns.map((_, i) => `$${i + 1}`)
|
|
114
117
|
|
|
115
|
-
await
|
|
116
|
-
`INSERT INTO ${
|
|
118
|
+
await pool.query(
|
|
119
|
+
`INSERT INTO ${eventName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
|
|
117
120
|
values
|
|
118
121
|
)
|
|
119
122
|
|
|
120
123
|
this.json(res, 200, { ok: true })
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
private async handleBatchWrite(
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
const available = Object.keys(this.
|
|
126
|
+
private async handleBatchWrite(eventName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
127
|
+
const eventDef = this.events[eventName]
|
|
128
|
+
if (!eventDef) {
|
|
129
|
+
const available = Object.keys(this.events)
|
|
127
130
|
this.json(res, 404, {
|
|
128
|
-
error: `
|
|
129
|
-
|
|
131
|
+
error: `Event '${eventName}' not found.`,
|
|
132
|
+
available_events: available,
|
|
130
133
|
})
|
|
131
134
|
return
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
const body = await this.readBody(req)
|
|
135
|
-
const
|
|
138
|
+
const items: any[] = JSON.parse(body)
|
|
136
139
|
|
|
137
|
-
if (!Array.isArray(
|
|
140
|
+
if (!Array.isArray(items)) {
|
|
138
141
|
this.json(res, 400, { error: 'Batch endpoint expects a JSON array.' })
|
|
139
142
|
return
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
if (items.length === 0) {
|
|
146
|
+
this.json(res, 200, { ok: true, count: 0 })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const pool = this.ensurePool()
|
|
151
|
+
const columns = Object.keys(eventDef.columns)
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
// Build a single INSERT with multiple VALUE rows
|
|
154
|
+
const allValues: unknown[] = []
|
|
155
|
+
const rowPlaceholders: string[] = []
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < items.length; i++) {
|
|
158
|
+
const row = items[i]
|
|
159
|
+
const offset = i * columns.length
|
|
160
|
+
const ph = columns.map((_, j) => `$${offset + j + 1}`)
|
|
161
|
+
rowPlaceholders.push(`(${ph.join(', ')})`)
|
|
162
|
+
for (const col of columns) {
|
|
163
|
+
allValues.push(row[col])
|
|
164
|
+
}
|
|
152
165
|
}
|
|
153
166
|
|
|
154
|
-
|
|
167
|
+
await pool.query(
|
|
168
|
+
`INSERT INTO ${eventName} (${columns.join(', ')}) VALUES ${rowPlaceholders.join(', ')}`,
|
|
169
|
+
allValues
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
this.json(res, 200, { ok: true, count: items.length })
|
|
155
173
|
}
|
|
156
174
|
|
|
157
|
-
private async handleRead(
|
|
158
|
-
if (!this.
|
|
159
|
-
const available = Object.keys(this.
|
|
175
|
+
private async handleRead(queryName: string, url: URL, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
176
|
+
if (!this.queries[queryName]) {
|
|
177
|
+
const available = Object.keys(this.queries)
|
|
160
178
|
this.json(res, 404, {
|
|
161
|
-
error: `
|
|
162
|
-
|
|
179
|
+
error: `Query '${queryName}' not found.`,
|
|
180
|
+
available_queries: available,
|
|
163
181
|
})
|
|
164
182
|
return
|
|
165
183
|
}
|
|
166
184
|
|
|
167
|
-
|
|
185
|
+
// JWT verification for queries with filterBy
|
|
186
|
+
const queryDef = this.queries[queryName]
|
|
187
|
+
const filterBy = this.getFilterBy(queryDef)
|
|
188
|
+
|
|
189
|
+
let claims: JwtClaims | null = null
|
|
190
|
+
if (filterBy && this.jwt?.isConfigured()) {
|
|
191
|
+
const token = url.searchParams.get('token')
|
|
192
|
+
?? req.headers.authorization?.replace('Bearer ', '')
|
|
193
|
+
|
|
194
|
+
if (!token) {
|
|
195
|
+
this.json(res, 401, { error: 'Authentication required for filtered queries.' })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
claims = await this.jwt.verify(token)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const pool = this.ensurePool()
|
|
168
203
|
|
|
169
|
-
// Build WHERE clause
|
|
204
|
+
// Build WHERE clause: start with filterBy if applicable
|
|
170
205
|
const params: string[] = []
|
|
171
206
|
const values: unknown[] = []
|
|
207
|
+
|
|
208
|
+
if (filterBy && claims) {
|
|
209
|
+
const claimValue = claims[filterBy]
|
|
210
|
+
if (claimValue === undefined) {
|
|
211
|
+
// No matching claim -- return empty result, not all data
|
|
212
|
+
this.json(res, 200, { query: queryName, rows: [] })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
values.push(String(claimValue))
|
|
216
|
+
params.push(`${filterBy} = $${values.length}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Add query params as additional filters, validating column names
|
|
220
|
+
const knownColumns = this.getQueryColumns(queryDef)
|
|
172
221
|
for (const [key, value] of url.searchParams.entries()) {
|
|
173
|
-
|
|
222
|
+
if (key === 'token') continue // skip JWT token param
|
|
223
|
+
if (knownColumns && !knownColumns.includes(key)) {
|
|
224
|
+
this.json(res, 400, { error: `Unknown column '${key}'. Known columns: ${knownColumns.join(', ')}` })
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
|
228
|
+
this.json(res, 400, { error: `Invalid column name: '${key}'` })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
174
231
|
values.push(value)
|
|
232
|
+
params.push(`${key} = $${values.length}`)
|
|
175
233
|
}
|
|
176
234
|
|
|
177
|
-
let sql = `SELECT * FROM ${
|
|
235
|
+
let sql = `SELECT * FROM ${queryName}`
|
|
178
236
|
if (params.length > 0) {
|
|
179
237
|
sql += ` WHERE ${params.join(' AND ')}`
|
|
180
238
|
}
|
|
181
239
|
|
|
182
|
-
const result = await
|
|
183
|
-
this.json(res, 200, {
|
|
240
|
+
const result = await pool.query(sql, values)
|
|
241
|
+
this.json(res, 200, { query: queryName, rows: result.rows })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private getFilterBy(queryDef: QueryDef | SqlFragment): string | undefined {
|
|
245
|
+
if ('_tag' in queryDef && queryDef._tag === 'sql') return undefined
|
|
246
|
+
return (queryDef as QueryDef).filterBy
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private getQueryColumns(queryDef: QueryDef | SqlFragment): string[] | null {
|
|
250
|
+
if ('_tag' in queryDef && queryDef._tag === 'sql') return null
|
|
251
|
+
const qd = queryDef as QueryDef
|
|
252
|
+
if (qd.columns) return Object.keys(qd.columns)
|
|
253
|
+
return null
|
|
184
254
|
}
|
|
185
255
|
|
|
186
256
|
private json(res: ServerResponse, status: number, data: unknown): void {
|
|
@@ -191,7 +261,16 @@ export class HttpApi {
|
|
|
191
261
|
private readBody(req: IncomingMessage): Promise<string> {
|
|
192
262
|
return new Promise((resolve, reject) => {
|
|
193
263
|
let body = ''
|
|
194
|
-
|
|
264
|
+
let size = 0
|
|
265
|
+
req.on('data', (chunk) => {
|
|
266
|
+
size += chunk.length
|
|
267
|
+
if (size > MAX_BODY_SIZE) {
|
|
268
|
+
req.destroy()
|
|
269
|
+
reject(new Error(`Request body exceeds ${MAX_BODY_SIZE / 1024 / 1024}MB limit`))
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
body += chunk
|
|
273
|
+
})
|
|
195
274
|
req.on('end', () => resolve(body))
|
|
196
275
|
req.on('error', reject)
|
|
197
276
|
})
|
package/src/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse, type Server }
|
|
|
2
2
|
import type { WaveletConfig } from '@risingwave/wavelet'
|
|
3
3
|
import { CursorManager } from './cursor-manager.js'
|
|
4
4
|
import { WebSocketFanout } from './ws-fanout.js'
|
|
5
|
+
import { WebhookFanout } from './webhook.js'
|
|
5
6
|
import { HttpApi } from './http-api.js'
|
|
6
7
|
import { JwtVerifier } from './jwt.js'
|
|
7
8
|
|
|
@@ -9,14 +10,18 @@ export class WaveletServer {
|
|
|
9
10
|
private httpServer: ReturnType<typeof createServer> | null = null
|
|
10
11
|
private cursorManager: CursorManager
|
|
11
12
|
private fanout: WebSocketFanout
|
|
13
|
+
private webhooks: WebhookFanout
|
|
12
14
|
private httpApi: HttpApi
|
|
13
15
|
private jwt: JwtVerifier
|
|
14
16
|
|
|
15
17
|
constructor(private config: WaveletConfig) {
|
|
16
18
|
this.jwt = new JwtVerifier(config.jwt)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this.
|
|
19
|
+
const queries = config.queries ?? config.views ?? {}
|
|
20
|
+
const events = config.events ?? config.streams ?? {}
|
|
21
|
+
this.cursorManager = new CursorManager(config.database, queries)
|
|
22
|
+
this.fanout = new WebSocketFanout(this.cursorManager, this.jwt, queries)
|
|
23
|
+
this.webhooks = new WebhookFanout(queries, config.jwt?.secret)
|
|
24
|
+
this.httpApi = new HttpApi(config.database, events, queries, this.jwt)
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
async start(): Promise<void> {
|
|
@@ -27,14 +32,15 @@ export class WaveletServer {
|
|
|
27
32
|
this.fanout.attach(this.httpServer)
|
|
28
33
|
|
|
29
34
|
await this.cursorManager.initialize()
|
|
30
|
-
this.cursorManager.startPolling((
|
|
31
|
-
this.fanout.broadcast(
|
|
35
|
+
this.cursorManager.startPolling((queryName, diffs) => {
|
|
36
|
+
this.fanout.broadcast(queryName, diffs)
|
|
37
|
+
this.webhooks.broadcast(queryName, diffs)
|
|
32
38
|
})
|
|
33
39
|
|
|
34
40
|
await new Promise<void>((resolve) => {
|
|
35
41
|
this.httpServer!.listen(port, host, () => {
|
|
36
42
|
console.log(`Wavelet server listening on ${host}:${port}`)
|
|
37
|
-
console.log(`WebSocket: ws://${host}:${port}/subscribe/{
|
|
43
|
+
console.log(`WebSocket: ws://${host}:${port}/subscribe/{query}`)
|
|
38
44
|
console.log(`HTTP API: http://${host}:${port}/v1/`)
|
|
39
45
|
resolve()
|
|
40
46
|
})
|
|
@@ -53,8 +59,9 @@ export class WaveletServer {
|
|
|
53
59
|
this.fanout.attach(server, prefix)
|
|
54
60
|
|
|
55
61
|
await this.cursorManager.initialize()
|
|
56
|
-
this.cursorManager.startPolling((
|
|
57
|
-
this.fanout.broadcast(
|
|
62
|
+
this.cursorManager.startPolling((queryName, diffs) => {
|
|
63
|
+
this.fanout.broadcast(queryName, diffs)
|
|
64
|
+
this.webhooks.broadcast(queryName, diffs)
|
|
58
65
|
})
|
|
59
66
|
|
|
60
67
|
const handleHttp = (req: IncomingMessage, res: ServerResponse) => {
|