@risingwave/wavelet-server 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/__tests__/cursor-parsing.test.d.ts +2 -0
  2. package/dist/__tests__/cursor-parsing.test.d.ts.map +1 -0
  3. package/dist/__tests__/cursor-parsing.test.js +64 -0
  4. package/dist/__tests__/cursor-parsing.test.js.map +1 -0
  5. package/dist/__tests__/http-api.test.d.ts +2 -0
  6. package/dist/__tests__/http-api.test.d.ts.map +1 -0
  7. package/dist/__tests__/http-api.test.js +135 -0
  8. package/dist/__tests__/http-api.test.js.map +1 -0
  9. package/dist/__tests__/integration.test.d.ts +2 -0
  10. package/dist/__tests__/integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/integration.test.js +229 -0
  12. package/dist/__tests__/integration.test.js.map +1 -0
  13. package/dist/__tests__/jwt.test.d.ts +2 -0
  14. package/dist/__tests__/jwt.test.d.ts.map +1 -0
  15. package/dist/__tests__/jwt.test.js +86 -0
  16. package/dist/__tests__/jwt.test.js.map +1 -0
  17. package/dist/__tests__/ws-fanout.test.d.ts +2 -0
  18. package/dist/__tests__/ws-fanout.test.d.ts.map +1 -0
  19. package/dist/__tests__/ws-fanout.test.js +127 -0
  20. package/dist/__tests__/ws-fanout.test.js.map +1 -0
  21. package/dist/config-loader.d.ts +3 -0
  22. package/dist/config-loader.d.ts.map +1 -0
  23. package/dist/config-loader.js +25 -0
  24. package/dist/config-loader.js.map +1 -0
  25. package/dist/cursor-manager.d.ts +54 -0
  26. package/dist/cursor-manager.d.ts.map +1 -0
  27. package/dist/cursor-manager.js +263 -0
  28. package/dist/cursor-manager.js.map +1 -0
  29. package/dist/ddl-manager.d.ts +33 -0
  30. package/dist/ddl-manager.d.ts.map +1 -0
  31. package/dist/ddl-manager.js +364 -0
  32. package/dist/ddl-manager.js.map +1 -0
  33. package/dist/http-api.d.ts +21 -0
  34. package/dist/http-api.d.ts.map +1 -0
  35. package/dist/http-api.js +242 -0
  36. package/dist/http-api.js.map +1 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +32 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/jwt.d.ts +16 -0
  42. package/dist/jwt.d.ts.map +1 -0
  43. package/dist/jwt.js +87 -0
  44. package/dist/jwt.js.map +1 -0
  45. package/dist/server.d.ts +24 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +82 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/webhook.d.ts +9 -0
  50. package/dist/webhook.d.ts.map +1 -0
  51. package/dist/webhook.js +63 -0
  52. package/dist/webhook.js.map +1 -0
  53. package/dist/ws-fanout.d.ts +24 -0
  54. package/dist/ws-fanout.d.ts.map +1 -0
  55. package/dist/ws-fanout.js +198 -0
  56. package/dist/ws-fanout.js.map +1 -0
  57. package/package.json +7 -3
  58. package/src/__tests__/cursor-parsing.test.ts +0 -68
  59. package/src/__tests__/http-api.test.ts +0 -130
  60. package/src/__tests__/integration.test.ts +0 -249
  61. package/src/__tests__/jwt.test.ts +0 -62
  62. package/src/__tests__/ws-fanout.test.ts +0 -143
  63. package/src/config-loader.ts +0 -28
  64. package/src/cursor-manager.ts +0 -311
  65. package/src/ddl-manager.ts +0 -408
  66. package/src/http-api.ts +0 -278
  67. package/src/index.ts +0 -31
  68. package/src/jwt.ts +0 -56
  69. package/src/server.ts +0 -89
  70. package/src/webhook.ts +0 -67
  71. package/src/ws-fanout.ts +0 -245
  72. package/tsconfig.json +0 -8
@@ -1,408 +0,0 @@
1
- import pg from 'pg'
2
- import type { WaveletConfig, EventDef, QueryDef, SqlFragment, PostgresCdcSource } from '@risingwave/wavelet'
3
-
4
- const { Client } = pg
5
-
6
- export interface DdlAction {
7
- type: 'create' | 'update' | 'delete' | 'unchanged'
8
- resource: 'event' | 'source' | 'query' | 'subscription'
9
- name: string
10
- detail?: string
11
- }
12
-
13
- const COLUMN_TYPE_MAP: Record<string, string> = {
14
- string: 'VARCHAR',
15
- int: 'INT',
16
- float: 'DOUBLE',
17
- boolean: 'BOOLEAN',
18
- timestamp: 'TIMESTAMPTZ',
19
- json: 'JSONB',
20
- }
21
-
22
- function normalizeSql(sql: string): string {
23
- // RisingWave stores definitions as "CREATE MATERIALIZED VIEW name AS SELECT ..."
24
- // Strip the prefix to compare just the query part
25
- const stripped = sql.replace(/^create\s+materialized\s+view\s+\S+\s+as\s+/i, '')
26
- return stripped.replace(/\s+/g, ' ').trim().toLowerCase()
27
- }
28
-
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
- }
33
-
34
- function buildCreateTableSql(name: string, eventDef: EventDef): string {
35
- const cols = Object.entries(eventDef.columns)
36
- .map(([colName, colType]) => {
37
- const sqlType = COLUMN_TYPE_MAP[colType]
38
- if (!sqlType) throw new Error(`Unknown column type "${colType}" for column "${colName}"`)
39
- return `${colName} ${sqlType}`
40
- })
41
- .join(', ')
42
- return `CREATE TABLE ${name} (${cols})`
43
- }
44
-
45
- export class DdlManager {
46
- private client: InstanceType<typeof Client> | null = null
47
-
48
- constructor(private connectionString: string) {}
49
-
50
- async connect(): Promise<void> {
51
- this.client = new Client({ connectionString: this.connectionString })
52
- await this.client.connect()
53
- console.log('[ddl-manager] Connected to RisingWave')
54
- }
55
-
56
- async close(): Promise<void> {
57
- await this.client?.end()
58
- this.client = null
59
- console.log('[ddl-manager] Connection closed')
60
- }
61
-
62
- /**
63
- * Sync all events, queries, and subscriptions to match the config.
64
- * Returns a list of actions taken.
65
- * Idempotent - safe to call multiple times.
66
- */
67
- async sync(config: WaveletConfig): Promise<DdlAction[]> {
68
- if (!this.client) throw new Error('Not connected - call connect() first')
69
-
70
- const actions: DdlAction[] = []
71
-
72
- // 1. Fetch existing state from RisingWave
73
- const existingTables = await this.getExistingTables()
74
- const existingMVs = await this.getExistingMaterializedViews()
75
- const existingSubscriptions = await this.getExistingSubscriptions()
76
-
77
- const desiredEvents = config.events ?? config.streams ?? {}
78
- const desiredSources = config.sources ?? {}
79
- const desiredQueries = config.queries ?? config.views ?? {}
80
-
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
-
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
- } else {
90
- await this.createTable(eventName, eventDef)
91
- actions.push({ type: 'create', resource: 'event', name: eventName })
92
- }
93
- }
94
-
95
- // 3b. Sync CDC sources - create Postgres CDC tables
96
- const existingSources = await this.getExistingSources()
97
- for (const [sourceName, sourceDef] of Object.entries(desiredSources)) {
98
- if (sourceDef.type === 'postgres') {
99
- for (const tableName of sourceDef.tables) {
100
- const cdcTableName = `${sourceName}_${tableName}`
101
- if (existingSources.has(cdcTableName) || existingTables.has(cdcTableName)) {
102
- actions.push({ type: 'unchanged', resource: 'source', name: cdcTableName })
103
- } else {
104
- await this.createCdcSource(sourceName, tableName, sourceDef)
105
- actions.push({ type: 'create', resource: 'source', name: cdcTableName })
106
- }
107
- }
108
- }
109
- }
110
-
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
-
117
- if (existingSql === undefined) {
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
-
122
- await this.createSubscription(subName, queryName)
123
- actions.push({ type: 'create', resource: 'subscription', name: subName })
124
- } else if (normalizeSql(existingSql) !== normalizeSql(desiredSql)) {
125
- // Query SQL changed - drop subscription, drop MV, recreate
126
- if (existingSubscriptions.has(subName)) {
127
- await this.dropSubscription(subName)
128
- actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'dropped for query update' })
129
- }
130
-
131
- await this.dropMaterializedView(queryName)
132
- actions.push({ type: 'delete', resource: 'query', name: queryName, detail: 'dropped for update' })
133
-
134
- await this.createMaterializedView(queryName, desiredSql)
135
- actions.push({ type: 'create', resource: 'query', name: queryName, detail: 'recreated with updated SQL' })
136
-
137
- await this.createSubscription(subName, queryName)
138
- actions.push({ type: 'create', resource: 'subscription', name: subName, detail: 'recreated after query update' })
139
- } else {
140
- // Query SQL unchanged
141
- actions.push({ type: 'unchanged', resource: 'query', name: queryName })
142
-
143
- // Ensure subscription exists even if the query is unchanged
144
- if (!existingSubscriptions.has(subName)) {
145
- await this.createSubscription(subName, queryName)
146
- actions.push({ type: 'create', resource: 'subscription', name: subName })
147
- } else {
148
- actions.push({ type: 'unchanged', resource: 'subscription', name: subName })
149
- }
150
- }
151
- }
152
-
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
-
158
- // Drop subscription first
159
- if (existingSubscriptions.has(subName)) {
160
- await this.dropSubscription(subName)
161
- actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'query removed from config' })
162
- }
163
-
164
- await this.dropMaterializedView(existingMVName)
165
- actions.push({ type: 'delete', resource: 'query', name: existingMVName, detail: 'removed from config' })
166
- }
167
- }
168
-
169
- // 6. Remove orphaned subscriptions that are no longer needed
170
- for (const existingSubName of existingSubscriptions) {
171
- // Only manage wavelet-prefixed subscriptions
172
- if (!existingSubName.startsWith('wavelet_sub_')) continue
173
-
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
- await this.dropSubscription(existingSubName)
179
- actions.push({ type: 'delete', resource: 'subscription', name: existingSubName, detail: 'orphaned subscription' })
180
- }
181
- }
182
- }
183
-
184
- // 7. Remove events (tables) that are no longer in the config
185
- for (const existingTableName of existingTables) {
186
- if (!desiredEventNames.has(existingTableName)) {
187
- // Only drop if no MV depends on it
188
- const hasDependents = await this.tableHasDependentMVs(existingTableName)
189
- if (hasDependents) {
190
- console.log(`[ddl-manager] Skipping drop of table "${existingTableName}" - materialized views depend on it`)
191
- actions.push({
192
- type: 'unchanged',
193
- resource: 'event',
194
- name: existingTableName,
195
- detail: 'kept because dependent materialized views exist',
196
- })
197
- } else {
198
- await this.dropTable(existingTableName)
199
- actions.push({ type: 'delete', resource: 'event', name: existingTableName, detail: 'removed from config' })
200
- }
201
- }
202
- }
203
-
204
- return actions
205
- }
206
-
207
- // ── Query helpers ─────────────────────────────────────────────────────
208
-
209
- private async getExistingTables(): Promise<Set<string>> {
210
- const result = await this.client!.query(
211
- `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`
212
- )
213
- return new Set(result.rows.map((r: any) => r.table_name))
214
- }
215
-
216
- private async getExistingMaterializedViews(): Promise<Map<string, string>> {
217
- const result = await this.client!.query(
218
- `SELECT name, definition FROM rw_catalog.rw_materialized_views WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public')`
219
- )
220
- const views = new Map<string, string>()
221
- for (const row of result.rows) {
222
- views.set(row.name, row.definition)
223
- }
224
- return views
225
- }
226
-
227
- private async getExistingSources(): Promise<Set<string>> {
228
- try {
229
- const result = await this.client!.query(
230
- `SELECT name FROM rw_catalog.rw_tables WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public') AND is_index = false`
231
- )
232
- return new Set(result.rows.map((r: any) => r.name))
233
- } catch {
234
- // Fallback: if catalog query fails, return empty set
235
- return new Set()
236
- }
237
- }
238
-
239
- private async getExistingSubscriptions(): Promise<Set<string>> {
240
- const result = await this.client!.query(
241
- `SELECT name FROM rw_catalog.rw_subscriptions WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public')`
242
- )
243
- return new Set(result.rows.map((r: any) => r.name))
244
- }
245
-
246
- private async tableHasDependentMVs(tableName: string): Promise<boolean> {
247
- const result = await this.client!.query(
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
- [`%${tableName}%`]
250
- )
251
- return result.rows.length > 0
252
- }
253
-
254
- // ── DDL operations ────────────────────────────────────────────────────
255
-
256
- private async createTable(name: string, eventDef: EventDef): Promise<void> {
257
- const sql = buildCreateTableSql(name, eventDef)
258
- try {
259
- await this.client!.query(sql)
260
- console.log(`[ddl-manager] Created table: ${name}`)
261
- } catch (err: any) {
262
- if (err.message?.includes('already exists')) {
263
- console.log(`[ddl-manager] Table already exists: ${name}`)
264
- } else {
265
- throw err
266
- }
267
- }
268
- }
269
-
270
- private async dropTable(name: string): Promise<void> {
271
- try {
272
- await this.client!.query(`DROP TABLE ${name}`)
273
- console.log(`[ddl-manager] Dropped table: ${name}`)
274
- } catch (err: any) {
275
- if (err.message?.includes('does not exist')) {
276
- console.log(`[ddl-manager] Table already gone: ${name}`)
277
- } else {
278
- throw err
279
- }
280
- }
281
- }
282
-
283
- private async createMaterializedView(name: string, sql: string): Promise<void> {
284
- try {
285
- await this.client!.query(`CREATE MATERIALIZED VIEW ${name} AS ${sql}`)
286
- console.log(`[ddl-manager] Created materialized view: ${name}`)
287
- } catch (err: any) {
288
- if (err.message?.includes('already exists')) {
289
- console.log(`[ddl-manager] Materialized view already exists: ${name}`)
290
- } else {
291
- throw err
292
- }
293
- }
294
- }
295
-
296
- private async dropMaterializedView(name: string): Promise<void> {
297
- try {
298
- await this.client!.query(`DROP MATERIALIZED VIEW ${name}`)
299
- console.log(`[ddl-manager] Dropped materialized view: ${name}`)
300
- } catch (err: any) {
301
- if (err.message?.includes('does not exist')) {
302
- console.log(`[ddl-manager] Materialized view already gone: ${name}`)
303
- } else {
304
- throw err
305
- }
306
- }
307
- }
308
-
309
- private async createSubscription(subName: string, viewName: string): Promise<void> {
310
- try {
311
- await this.client!.query(
312
- `CREATE SUBSCRIPTION ${subName} FROM ${viewName} WITH (retention = '24h')`
313
- )
314
- console.log(`[ddl-manager] Created subscription: ${subName}`)
315
- } catch (err: any) {
316
- if (err.message?.includes('already exists')) {
317
- console.log(`[ddl-manager] Subscription already exists: ${subName}`)
318
- } else {
319
- throw err
320
- }
321
- }
322
- }
323
-
324
- private async dropSubscription(subName: string): Promise<void> {
325
- try {
326
- await this.client!.query(`DROP SUBSCRIPTION ${subName}`)
327
- console.log(`[ddl-manager] Dropped subscription: ${subName}`)
328
- } catch (err: any) {
329
- if (err.message?.includes('does not exist')) {
330
- console.log(`[ddl-manager] Subscription already gone: ${subName}`)
331
- } else {
332
- throw err
333
- }
334
- }
335
- }
336
-
337
- private async createCdcSource(
338
- sourceName: string,
339
- tableName: string,
340
- source: PostgresCdcSource
341
- ): Promise<void> {
342
- const cdcTableName = `${sourceName}_${tableName}`
343
- const slotName = source.slotName ?? `wavelet_${sourceName}`
344
- const pubName = source.publicationName ?? `wavelet_${sourceName}_pub`
345
-
346
- // RisingWave CDC source syntax:
347
- // CREATE TABLE table_name ( ... ) WITH (
348
- // connector = 'postgres-cdc',
349
- // hostname = '...',
350
- // port = '...',
351
- // username = '...',
352
- // password = '...',
353
- // database.name = '...',
354
- // table.name = '...',
355
- // slot.name = '...',
356
- // publication.name = '...'
357
- // )
358
- // We parse the connection string to extract components.
359
-
360
- const parsed = parsePostgresUrl(source.connection)
361
-
362
- const esc = (s: string) => s.replace(/'/g, "''")
363
- try {
364
- await this.client!.query(`
365
- CREATE TABLE IF NOT EXISTS ${cdcTableName} (*)
366
- WITH (
367
- connector = 'postgres-cdc',
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)}'
377
- )
378
- `)
379
- console.log(`[ddl-manager] Created CDC source: ${cdcTableName} (from ${parsed.host}/${parsed.database}.${tableName})`)
380
- } catch (err: any) {
381
- if (err.message?.includes('already exists')) {
382
- console.log(`[ddl-manager] CDC source already exists: ${cdcTableName}`)
383
- } else {
384
- throw err
385
- }
386
- }
387
- }
388
- }
389
-
390
- function parsePostgresUrl(url: string): {
391
- host: string
392
- port: string
393
- user: string
394
- password: string
395
- database: string
396
- schema: string
397
- } {
398
- // Parse postgresql://user:password@host:port/database?schema=xxx
399
- const u = new URL(url)
400
- return {
401
- host: u.hostname,
402
- port: u.port || '5432',
403
- user: decodeURIComponent(u.username),
404
- password: decodeURIComponent(u.password),
405
- database: u.pathname.replace(/^\//, '') || 'postgres',
406
- schema: u.searchParams.get('schema') ?? 'public',
407
- }
408
- }
package/src/http-api.ts DELETED
@@ -1,278 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http'
2
- import pg from 'pg'
3
- import type { EventDef, QueryDef, SqlFragment } from '@risingwave/wavelet'
4
- import type { JwtVerifier, JwtClaims } from './jwt.js'
5
-
6
- const { Pool } = pg
7
-
8
- const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
9
-
10
- export class HttpApi {
11
- private pool: InstanceType<typeof Pool> | null = null
12
-
13
- constructor(
14
- private connectionString: string,
15
- private events: Record<string, EventDef>,
16
- private queries: Record<string, QueryDef | SqlFragment>,
17
- private jwt?: JwtVerifier
18
- ) {}
19
-
20
- async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
21
- // CORS
22
- res.setHeader('Access-Control-Allow-Origin', '*')
23
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
24
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
25
-
26
- if (req.method === 'OPTIONS') {
27
- res.writeHead(204)
28
- res.end()
29
- return
30
- }
31
-
32
- const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
33
-
34
- try {
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)
39
- return
40
- }
41
-
42
- // POST /v1/events/{name}/batch
43
- const batchMatch = url.pathname.match(/^\/v1\/events\/([^/]+)\/batch$/)
44
- if (batchMatch && req.method === 'POST') {
45
- await this.handleBatchWrite(batchMatch[1], req, res)
46
- return
47
- }
48
-
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)
53
- return
54
- }
55
-
56
- // GET /v1/health
57
- if (url.pathname === '/v1/health') {
58
- this.json(res, 200, { status: 'ok' })
59
- return
60
- }
61
-
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) })
65
- return
66
- }
67
-
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) })
71
- return
72
- }
73
-
74
- this.json(res, 404, {
75
- error: 'Not found',
76
- message: `${req.method} ${url.pathname} does not match any route.`,
77
- routes: [
78
- 'GET /v1/health',
79
- 'GET /v1/queries',
80
- 'GET /v1/queries/{name}',
81
- 'GET /v1/events',
82
- 'POST /v1/events/{name}',
83
- 'POST /v1/events/{name}/batch',
84
- ],
85
- })
86
- } catch (err: any) {
87
- console.error('HTTP error:', err)
88
- this.json(res, 500, { error: err.message })
89
- }
90
- }
91
-
92
- private ensurePool(): InstanceType<typeof Pool> {
93
- if (!this.pool) {
94
- this.pool = new Pool({ connectionString: this.connectionString, max: 10 })
95
- }
96
- return this.pool
97
- }
98
-
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)
103
- this.json(res, 404, {
104
- error: `Event '${eventName}' not found.`,
105
- available_events: available,
106
- })
107
- return
108
- }
109
-
110
- const body = await this.readBody(req)
111
- const data = JSON.parse(body)
112
-
113
- const pool = this.ensurePool()
114
- const columns = Object.keys(eventDef.columns)
115
- const values = columns.map((col) => data[col])
116
- const placeholders = columns.map((_, i) => `$${i + 1}`)
117
-
118
- await pool.query(
119
- `INSERT INTO ${eventName} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
120
- values
121
- )
122
-
123
- this.json(res, 200, { ok: true })
124
- }
125
-
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)
130
- this.json(res, 404, {
131
- error: `Event '${eventName}' not found.`,
132
- available_events: available,
133
- })
134
- return
135
- }
136
-
137
- const body = await this.readBody(req)
138
- const items: any[] = JSON.parse(body)
139
-
140
- if (!Array.isArray(items)) {
141
- this.json(res, 400, { error: 'Batch endpoint expects a JSON array.' })
142
- return
143
- }
144
-
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)
152
-
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
- }
165
- }
166
-
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 })
173
- }
174
-
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)
178
- this.json(res, 404, {
179
- error: `Query '${queryName}' not found.`,
180
- available_queries: available,
181
- })
182
- return
183
- }
184
-
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()
203
-
204
- // Build WHERE clause: start with filterBy if applicable
205
- const params: string[] = []
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)
221
- for (const [key, value] of url.searchParams.entries()) {
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
- }
231
- values.push(value)
232
- params.push(`${key} = $${values.length}`)
233
- }
234
-
235
- let sql = `SELECT * FROM ${queryName}`
236
- if (params.length > 0) {
237
- sql += ` WHERE ${params.join(' AND ')}`
238
- }
239
-
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
254
- }
255
-
256
- private json(res: ServerResponse, status: number, data: unknown): void {
257
- res.writeHead(status, { 'Content-Type': 'application/json' })
258
- res.end(JSON.stringify(data))
259
- }
260
-
261
- private readBody(req: IncomingMessage): Promise<string> {
262
- return new Promise((resolve, reject) => {
263
- let body = ''
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
- })
274
- req.on('end', () => resolve(body))
275
- req.on('error', reject)
276
- })
277
- }
278
- }