@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.
@@ -1,11 +1,11 @@
1
1
  import pg from 'pg'
2
- import type { WaveletConfig, StreamDef, ViewDef, SqlFragment, PostgresCdcSource } from '@risingwave/wavelet'
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: 'stream' | 'source' | 'view' | 'subscription'
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 getViewQuery(viewDef: ViewDef | SqlFragment): string {
30
- if ('_tag' in viewDef && viewDef._tag === 'sql') return viewDef.text
31
- return (viewDef as ViewDef).query.text
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, streamDef: StreamDef): string {
35
- const cols = Object.entries(streamDef.columns)
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 streams, views, and subscriptions to match the config.
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 existingViews = await this.getExistingViews()
74
+ const existingMVs = await this.getExistingMaterializedViews()
75
75
  const existingSubscriptions = await this.getExistingSubscriptions()
76
76
 
77
- const desiredStreams = config.streams ?? {}
77
+ const desiredEvents = config.events ?? config.streams ?? {}
78
78
  const desiredSources = config.sources ?? {}
79
- const desiredViews = config.views ?? {}
79
+ const desiredQueries = config.queries ?? config.views ?? {}
80
80
 
81
- // 2. Determine which streams (tables) to create or remove
82
- const desiredStreamNames = new Set(Object.keys(desiredStreams))
83
- const desiredViewNames = new Set(Object.keys(desiredViews))
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 streams - create missing tables
86
- for (const [streamName, streamDef] of Object.entries(desiredStreams)) {
87
- if (existingTables.has(streamName)) {
88
- actions.push({ type: 'unchanged', resource: 'stream', name: streamName })
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(streamName, streamDef)
91
- actions.push({ type: 'create', resource: 'stream', name: streamName })
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 views - create, update, or leave unchanged
112
- for (const [viewName, viewDef] of Object.entries(desiredViews)) {
113
- const subName = `wavelet_sub_${viewName}`
114
- const desiredSql = getViewQuery(viewDef)
115
- const existingSql = existingViews.get(viewName)
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
- // View does not exist - create MV and subscription
119
- await this.createMaterializedView(viewName, desiredSql)
120
- actions.push({ type: 'create', resource: 'view', name: viewName })
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, viewName)
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
- // View SQL changed - drop subscription, drop MV, recreate
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 view update' })
128
+ actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'dropped for query update' })
129
129
  }
130
130
 
131
- await this.dropMaterializedView(viewName)
132
- actions.push({ type: 'delete', resource: 'view', name: viewName, detail: 'dropped for update' })
131
+ await this.dropMaterializedView(queryName)
132
+ actions.push({ type: 'delete', resource: 'query', name: queryName, detail: 'dropped for update' })
133
133
 
134
- await this.createMaterializedView(viewName, desiredSql)
135
- actions.push({ type: 'create', resource: 'view', name: viewName, detail: 'recreated with updated SQL' })
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, viewName)
138
- actions.push({ type: 'create', resource: 'subscription', name: subName, detail: 'recreated after view update' })
137
+ await this.createSubscription(subName, queryName)
138
+ actions.push({ type: 'create', resource: 'subscription', name: subName, detail: 'recreated after query update' })
139
139
  } else {
140
- // View SQL unchanged
141
- actions.push({ type: 'unchanged', resource: 'view', name: viewName })
140
+ // Query SQL unchanged
141
+ actions.push({ type: 'unchanged', resource: 'query', name: queryName })
142
142
 
143
- // Ensure subscription exists even if the view is unchanged
143
+ // Ensure subscription exists even if the query is unchanged
144
144
  if (!existingSubscriptions.has(subName)) {
145
- await this.createSubscription(subName, viewName)
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 views that are no longer in the config
154
- for (const [existingViewName] of existingViews) {
155
- if (!desiredViewNames.has(existingViewName)) {
156
- const subName = `wavelet_sub_${existingViewName}`
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: 'view removed from config' })
161
+ actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'query removed from config' })
162
162
  }
163
163
 
164
- await this.dropMaterializedView(existingViewName)
165
- actions.push({ type: 'delete', resource: 'view', name: existingViewName, detail: 'removed from config' })
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 viewName = existingSubName.slice('wavelet_sub_'.length)
175
- if (!desiredViewNames.has(viewName)) {
176
- // Already handled in step 5 if the view existed, but handle dangling subs too
177
- if (!existingViews.has(viewName)) {
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 streams (tables) that are no longer in the config
184
+ // 7. Remove events (tables) that are no longer in the config
185
185
  for (const existingTableName of existingTables) {
186
- if (!desiredStreamNames.has(existingTableName)) {
186
+ if (!desiredEventNames.has(existingTableName)) {
187
187
  // Only drop if no MV depends on it
188
- const hasDependents = await this.tableHasDependentViews(existingTableName)
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: 'stream',
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: 'stream', name: existingTableName, detail: 'removed from config' })
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 getExistingViews(): Promise<Map<string, string>> {
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 tableHasDependentViews(tableName: string): Promise<boolean> {
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, streamDef: StreamDef): Promise<void> {
257
- const sql = buildCreateTableSql(name, streamDef)
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 { StreamDef, ViewDef, SqlFragment } from '@risingwave/wavelet'
3
+ import type { EventDef, QueryDef, SqlFragment } from '@risingwave/wavelet'
4
+ import type { JwtVerifier, JwtClaims } from './jwt.js'
4
5
 
5
- const { Client } = pg
6
+ const { Pool } = pg
7
+
8
+ const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
6
9
 
7
10
  export class HttpApi {
8
- private client: InstanceType<typeof Client> | null = null
11
+ private pool: InstanceType<typeof Pool> | null = null
9
12
 
10
13
  constructor(
11
14
  private connectionString: string,
12
- private streams: Record<string, StreamDef>,
13
- private views: Record<string, ViewDef | SqlFragment>
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/streams/{name}
32
- const streamMatch = url.pathname.match(/^\/v1\/streams\/([^/]+)$/)
33
- if (streamMatch && req.method === 'POST') {
34
- await this.handleWrite(streamMatch[1], req, res)
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/streams/{name}/batch
39
- const batchMatch = url.pathname.match(/^\/v1\/streams\/([^/]+)\/batch$/)
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/views/{name}
46
- const viewMatch = url.pathname.match(/^\/v1\/views\/([^/]+)$/)
47
- if (viewMatch && req.method === 'GET') {
48
- await this.handleRead(viewMatch[1], url, res)
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/views - list available views
59
- if (url.pathname === '/v1/views' && req.method === 'GET') {
60
- this.json(res, 200, { views: Object.keys(this.views) })
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/streams - list available streams
65
- if (url.pathname === '/v1/streams' && req.method === 'GET') {
66
- this.json(res, 200, { streams: Object.keys(this.streams) })
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/views',
76
- 'GET /v1/views/{name}',
77
- 'GET /v1/streams',
78
- 'POST /v1/streams/{name}',
79
- 'POST /v1/streams/{name}/batch',
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 async ensureClient(): Promise<InstanceType<typeof Client>> {
89
- if (!this.client) {
90
- this.client = new Client({ connectionString: this.connectionString })
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.client
96
+ return this.pool
94
97
  }
95
98
 
96
- private async handleWrite(streamName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
97
- const stream = this.streams[streamName]
98
- if (!stream) {
99
- const available = Object.keys(this.streams)
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: `Stream '${streamName}' not found.`,
102
- available_streams: available,
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 client = await this.ensureClient()
111
- const columns = Object.keys(stream.columns)
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 client.query(
116
- `INSERT INTO ${streamName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
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(streamName: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
124
- const stream = this.streams[streamName]
125
- if (!stream) {
126
- const available = Object.keys(this.streams)
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: `Stream '${streamName}' not found.`,
129
- available_streams: available,
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 events: any[] = JSON.parse(body)
138
+ const items: any[] = JSON.parse(body)
136
139
 
137
- if (!Array.isArray(events)) {
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
- const client = await this.ensureClient()
143
- const columns = Object.keys(stream.columns)
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
- for (const data of events) {
146
- const values = columns.map((col) => data[col])
147
- const placeholders = columns.map((_, i) => `$${i + 1}`)
148
- await client.query(
149
- `INSERT INTO ${streamName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
150
- values
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
- this.json(res, 200, { ok: true, count: events.length })
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(viewName: string, url: URL, res: ServerResponse): Promise<void> {
158
- if (!this.views[viewName]) {
159
- const available = Object.keys(this.views)
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: `View '${viewName}' not found.`,
162
- available_views: available,
179
+ error: `Query '${queryName}' not found.`,
180
+ available_queries: available,
163
181
  })
164
182
  return
165
183
  }
166
184
 
167
- const client = await this.ensureClient()
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 from query params
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
- params.push(`${key} = $${params.length + 1}`)
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 ${viewName}`
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 client.query(sql, values)
183
- this.json(res, 200, { view: viewName, rows: result.rows })
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
- req.on('data', (chunk) => { body += chunk })
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
- this.cursorManager = new CursorManager(config.database, config.views ?? {})
18
- this.fanout = new WebSocketFanout(this.cursorManager, this.jwt, config.views ?? {})
19
- this.httpApi = new HttpApi(config.database, config.streams ?? {}, config.views ?? {})
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((viewName, diffs) => {
31
- this.fanout.broadcast(viewName, diffs)
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/{view}`)
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((viewName, diffs) => {
57
- this.fanout.broadcast(viewName, diffs)
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) => {