@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.
- package/dist/__tests__/cursor-parsing.test.d.ts +2 -0
- package/dist/__tests__/cursor-parsing.test.d.ts.map +1 -0
- package/dist/__tests__/cursor-parsing.test.js +64 -0
- package/dist/__tests__/cursor-parsing.test.js.map +1 -0
- package/dist/__tests__/http-api.test.d.ts +2 -0
- package/dist/__tests__/http-api.test.d.ts.map +1 -0
- package/dist/__tests__/http-api.test.js +135 -0
- package/dist/__tests__/http-api.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +229 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/jwt.test.d.ts +2 -0
- package/dist/__tests__/jwt.test.d.ts.map +1 -0
- package/dist/__tests__/jwt.test.js +86 -0
- package/dist/__tests__/jwt.test.js.map +1 -0
- package/dist/__tests__/ws-fanout.test.d.ts +2 -0
- package/dist/__tests__/ws-fanout.test.d.ts.map +1 -0
- package/dist/__tests__/ws-fanout.test.js +127 -0
- package/dist/__tests__/ws-fanout.test.js.map +1 -0
- package/dist/config-loader.d.ts +3 -0
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/config-loader.js +25 -0
- package/dist/config-loader.js.map +1 -0
- package/dist/cursor-manager.d.ts +54 -0
- package/dist/cursor-manager.d.ts.map +1 -0
- package/dist/cursor-manager.js +263 -0
- package/dist/cursor-manager.js.map +1 -0
- package/dist/ddl-manager.d.ts +33 -0
- package/dist/ddl-manager.d.ts.map +1 -0
- package/dist/ddl-manager.js +364 -0
- package/dist/ddl-manager.js.map +1 -0
- package/dist/http-api.d.ts +21 -0
- package/dist/http-api.d.ts.map +1 -0
- package/dist/http-api.js +242 -0
- package/dist/http-api.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +16 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +87 -0
- package/dist/jwt.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +82 -0
- package/dist/server.js.map +1 -0
- package/dist/webhook.d.ts +9 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +63 -0
- package/dist/webhook.js.map +1 -0
- package/dist/ws-fanout.d.ts +24 -0
- package/dist/ws-fanout.d.ts.map +1 -0
- package/dist/ws-fanout.js +198 -0
- package/dist/ws-fanout.js.map +1 -0
- package/package.json +7 -3
- package/src/__tests__/cursor-parsing.test.ts +0 -68
- package/src/__tests__/http-api.test.ts +0 -130
- package/src/__tests__/integration.test.ts +0 -249
- package/src/__tests__/jwt.test.ts +0 -62
- package/src/__tests__/ws-fanout.test.ts +0 -143
- package/src/config-loader.ts +0 -28
- package/src/cursor-manager.ts +0 -311
- package/src/ddl-manager.ts +0 -408
- package/src/http-api.ts +0 -278
- package/src/index.ts +0 -31
- package/src/jwt.ts +0 -56
- package/src/server.ts +0 -89
- package/src/webhook.ts +0 -67
- package/src/ws-fanout.ts +0 -245
- package/tsconfig.json +0 -8
package/src/config-loader.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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
|
-
|
|
9
|
-
// Handle various module formats:
|
|
10
|
-
// - ESM: mod.default is the config
|
|
11
|
-
// - CJS interop: mod.default.default is the config
|
|
12
|
-
// - Plain object: mod itself is the config
|
|
13
|
-
let config = mod.default ?? mod
|
|
14
|
-
if (config && typeof config === 'object' && 'default' in config) {
|
|
15
|
-
config = config.default
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (!config || !config.database) {
|
|
19
|
-
throw new Error(
|
|
20
|
-
`wavelet.config.ts must export a config with a 'database' field.\n` +
|
|
21
|
-
`Example:\n` +
|
|
22
|
-
` import { defineConfig } from '@risingwave/wavelet'\n` +
|
|
23
|
-
` export default defineConfig({ database: 'postgres://...' })`
|
|
24
|
-
)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return config
|
|
28
|
-
}
|
package/src/cursor-manager.ts
DELETED
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
import pg from 'pg'
|
|
2
|
-
import type { SqlFragment, QueryDef } from '@risingwave/wavelet'
|
|
3
|
-
|
|
4
|
-
const { Client } = pg
|
|
5
|
-
|
|
6
|
-
export interface DiffRow {
|
|
7
|
-
op: 'insert' | 'update_insert' | 'update_delete' | 'delete'
|
|
8
|
-
row: Record<string, unknown>
|
|
9
|
-
rw_timestamp: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ViewDiff {
|
|
13
|
-
cursor: string
|
|
14
|
-
inserted: Record<string, unknown>[]
|
|
15
|
-
updated: Record<string, unknown>[]
|
|
16
|
-
deleted: Record<string, unknown>[]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface BootstrapResult {
|
|
20
|
-
snapshotRows: Record<string, unknown>[]
|
|
21
|
-
diffs: ViewDiff[]
|
|
22
|
-
lastCursor: string | null
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
type DiffCallback = (queryName: string, diff: ViewDiff) => void
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Manages persistent subscription cursors against RisingWave.
|
|
29
|
-
*
|
|
30
|
-
* Each query gets its own dedicated pg connection and a persistent cursor.
|
|
31
|
-
* Uses blocking FETCH (WITH timeout) so there is no polling interval -
|
|
32
|
-
* diffs are dispatched as soon as RisingWave produces them.
|
|
33
|
-
*/
|
|
34
|
-
export class CursorManager {
|
|
35
|
-
// Shared connection for DDL (CREATE SUBSCRIPTION) and ad-hoc queries
|
|
36
|
-
private client: InstanceType<typeof Client> | null = null
|
|
37
|
-
|
|
38
|
-
// Per-query dedicated connections for blocking FETCH
|
|
39
|
-
private queryConnections: Map<string, InstanceType<typeof Client>> = new Map()
|
|
40
|
-
private cursorNames: Map<string, string> = new Map()
|
|
41
|
-
private subscriptions: Map<string, string> = new Map()
|
|
42
|
-
private running = false
|
|
43
|
-
|
|
44
|
-
constructor(
|
|
45
|
-
private connectionString: string,
|
|
46
|
-
private queries: Record<string, QueryDef | SqlFragment>
|
|
47
|
-
) {}
|
|
48
|
-
|
|
49
|
-
async initialize(): Promise<void> {
|
|
50
|
-
this.client = new Client({ connectionString: this.connectionString })
|
|
51
|
-
await this.client.connect()
|
|
52
|
-
console.log('Connected to RisingWave')
|
|
53
|
-
|
|
54
|
-
for (const [queryName] of Object.entries(this.queries)) {
|
|
55
|
-
const subName = `wavelet_sub_${queryName}`
|
|
56
|
-
|
|
57
|
-
// Create subscription if not exists (idempotent)
|
|
58
|
-
try {
|
|
59
|
-
await this.client.query(
|
|
60
|
-
`CREATE SUBSCRIPTION ${subName} FROM ${queryName} WITH (retention = '24h')`
|
|
61
|
-
)
|
|
62
|
-
console.log(`Created subscription: ${subName}`)
|
|
63
|
-
} catch (err: any) {
|
|
64
|
-
if (err.message?.includes('exists')) {
|
|
65
|
-
console.log(`Subscription exists: ${subName}`)
|
|
66
|
-
} else {
|
|
67
|
-
throw err
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
this.subscriptions.set(queryName, subName)
|
|
72
|
-
|
|
73
|
-
// Create dedicated connection and persistent cursor for this query
|
|
74
|
-
const conn = new Client({ connectionString: this.connectionString })
|
|
75
|
-
await conn.connect()
|
|
76
|
-
|
|
77
|
-
const cursorName = `wavelet_cur_${queryName}`
|
|
78
|
-
await conn.query(`DECLARE ${cursorName} SUBSCRIPTION CURSOR FOR ${subName}`)
|
|
79
|
-
console.log(`Opened persistent cursor: ${cursorName}`)
|
|
80
|
-
|
|
81
|
-
this.queryConnections.set(queryName, conn)
|
|
82
|
-
this.cursorNames.set(queryName, cursorName)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Start listening for diffs on all queries.
|
|
88
|
-
* Each query runs its own async loop with blocking FETCH.
|
|
89
|
-
* No polling interval - FETCH blocks until data arrives or timeout.
|
|
90
|
-
*/
|
|
91
|
-
startPolling(callback: DiffCallback): void {
|
|
92
|
-
this.running = true
|
|
93
|
-
|
|
94
|
-
for (const [queryName] of this.subscriptions.entries()) {
|
|
95
|
-
this.listenLoop(queryName, callback)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
stopPolling(): void {
|
|
100
|
-
this.running = false
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
private async listenLoop(queryName: string, callback: DiffCallback): Promise<void> {
|
|
104
|
-
const conn = this.queryConnections.get(queryName)
|
|
105
|
-
const cursorName = this.cursorNames.get(queryName)
|
|
106
|
-
if (!conn || !cursorName) return
|
|
107
|
-
|
|
108
|
-
while (this.running) {
|
|
109
|
-
try {
|
|
110
|
-
// Blocking FETCH: waits up to 5s for new data, returns immediately when data arrives
|
|
111
|
-
const result = await conn.query(
|
|
112
|
-
`FETCH NEXT FROM ${cursorName} WITH (timeout = '5s')`
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
if (result.rows.length === 0) continue
|
|
116
|
-
|
|
117
|
-
// Got at least one row. Drain any remaining rows with timeout.
|
|
118
|
-
const allRows = [...result.rows]
|
|
119
|
-
let more = true
|
|
120
|
-
while (more) {
|
|
121
|
-
const batch = await conn.query(
|
|
122
|
-
`FETCH 100 FROM ${cursorName} WITH (timeout = '1s')`
|
|
123
|
-
)
|
|
124
|
-
if (batch.rows.length > 0) {
|
|
125
|
-
allRows.push(...batch.rows)
|
|
126
|
-
} else {
|
|
127
|
-
more = false
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const diffs = this.parseDiffBatches(allRows)
|
|
132
|
-
|
|
133
|
-
for (const diff of diffs) {
|
|
134
|
-
if (diff.inserted.length === 0 && diff.updated.length === 0 && diff.deleted.length === 0) {
|
|
135
|
-
continue
|
|
136
|
-
}
|
|
137
|
-
callback(queryName, diff)
|
|
138
|
-
}
|
|
139
|
-
} catch (err: any) {
|
|
140
|
-
if (!this.running) return
|
|
141
|
-
console.error(`[cursor-manager] Error fetching ${queryName}:`, err.message)
|
|
142
|
-
// Back off on error, then retry
|
|
143
|
-
await new Promise(r => setTimeout(r, 1000))
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
parseDiffs(rows: any[]): ViewDiff {
|
|
149
|
-
const diff: ViewDiff = {
|
|
150
|
-
cursor: '',
|
|
151
|
-
inserted: [],
|
|
152
|
-
updated: [],
|
|
153
|
-
deleted: [],
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
for (const row of rows) {
|
|
157
|
-
const { op, rw_timestamp, ...data } = row
|
|
158
|
-
diff.cursor = rw_timestamp ?? diff.cursor
|
|
159
|
-
|
|
160
|
-
// RisingWave returns op as a string: "Insert", "Delete", "UpdateInsert", "UpdateDelete"
|
|
161
|
-
const opStr = String(op)
|
|
162
|
-
switch (opStr) {
|
|
163
|
-
case 'Insert':
|
|
164
|
-
case '1':
|
|
165
|
-
diff.inserted.push(data)
|
|
166
|
-
break
|
|
167
|
-
case 'Delete':
|
|
168
|
-
case '2':
|
|
169
|
-
diff.deleted.push(data)
|
|
170
|
-
break
|
|
171
|
-
case 'UpdateDelete':
|
|
172
|
-
case '3':
|
|
173
|
-
diff.deleted.push(data)
|
|
174
|
-
break
|
|
175
|
-
case 'UpdateInsert':
|
|
176
|
-
case '4':
|
|
177
|
-
diff.updated.push(data)
|
|
178
|
-
break
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return diff
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
parseDiffBatches(rows: any[]): ViewDiff[] {
|
|
186
|
-
const diffs: ViewDiff[] = []
|
|
187
|
-
let currentRows: any[] = []
|
|
188
|
-
let currentCursor: string | null = null
|
|
189
|
-
|
|
190
|
-
for (const row of rows) {
|
|
191
|
-
const cursor = this.normalizeCursor(row.rw_timestamp)
|
|
192
|
-
if (!cursor) continue
|
|
193
|
-
|
|
194
|
-
if (currentCursor !== null && cursor !== currentCursor) {
|
|
195
|
-
diffs.push(this.parseDiffs(currentRows))
|
|
196
|
-
currentRows = []
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
currentCursor = cursor
|
|
200
|
-
currentRows.push(row)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (currentRows.length > 0) {
|
|
204
|
-
diffs.push(this.parseDiffs(currentRows))
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return diffs
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async bootstrap(queryName: string): Promise<BootstrapResult> {
|
|
211
|
-
const subName = this.subscriptions.get(queryName)
|
|
212
|
-
if (!subName) {
|
|
213
|
-
throw new Error(
|
|
214
|
-
`Subscription for query '${queryName}' is not initialized. Start the server before accepting WebSocket clients.`
|
|
215
|
-
)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const conn = new Client({ connectionString: this.connectionString })
|
|
219
|
-
await conn.connect()
|
|
220
|
-
|
|
221
|
-
const cursorName = `wavelet_boot_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
await conn.query(`DECLARE ${cursorName} SUBSCRIPTION CURSOR FOR ${subName} FULL`)
|
|
225
|
-
|
|
226
|
-
const snapshotRows: Record<string, unknown>[] = []
|
|
227
|
-
const incrementalRows: any[] = []
|
|
228
|
-
let readingSnapshot = true
|
|
229
|
-
|
|
230
|
-
while (readingSnapshot) {
|
|
231
|
-
const result = await conn.query(`FETCH 1000 FROM ${cursorName}`)
|
|
232
|
-
if (result.rows.length === 0) break
|
|
233
|
-
|
|
234
|
-
let firstIncrementalIndex = result.rows.findIndex((row) => this.normalizeCursor(row.rw_timestamp) !== null)
|
|
235
|
-
if (firstIncrementalIndex === -1) firstIncrementalIndex = result.rows.length
|
|
236
|
-
|
|
237
|
-
for (const row of result.rows.slice(0, firstIncrementalIndex)) {
|
|
238
|
-
snapshotRows.push(this.stripSubscriptionMetadata(row))
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (firstIncrementalIndex < result.rows.length) {
|
|
242
|
-
incrementalRows.push(...result.rows.slice(firstIncrementalIndex))
|
|
243
|
-
readingSnapshot = false
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
while (true) {
|
|
248
|
-
const result = await conn.query(`FETCH 1000 FROM ${cursorName}`)
|
|
249
|
-
if (result.rows.length === 0) break
|
|
250
|
-
incrementalRows.push(...result.rows)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const diffs = this.parseDiffBatches(incrementalRows)
|
|
254
|
-
const lastCursor = diffs.length > 0 ? diffs[diffs.length - 1].cursor : null
|
|
255
|
-
|
|
256
|
-
return { snapshotRows, diffs, lastCursor }
|
|
257
|
-
} finally {
|
|
258
|
-
try {
|
|
259
|
-
await conn.query(`CLOSE ${cursorName}`)
|
|
260
|
-
} catch {}
|
|
261
|
-
try {
|
|
262
|
-
await conn.end()
|
|
263
|
-
} catch {}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async query(sql: string): Promise<any[]> {
|
|
268
|
-
if (!this.client) throw new Error('Not connected')
|
|
269
|
-
const result = await this.client.query(sql)
|
|
270
|
-
return result.rows
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async execute(sql: string): Promise<void> {
|
|
274
|
-
if (!this.client) throw new Error('Not connected')
|
|
275
|
-
await this.client.query(sql)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async close(): Promise<void> {
|
|
279
|
-
this.running = false
|
|
280
|
-
|
|
281
|
-
// Close per-query connections
|
|
282
|
-
for (const [queryName, conn] of this.queryConnections) {
|
|
283
|
-
const cursorName = this.cursorNames.get(queryName)
|
|
284
|
-
try {
|
|
285
|
-
if (cursorName) await conn.query(`CLOSE ${cursorName}`)
|
|
286
|
-
} catch {}
|
|
287
|
-
try {
|
|
288
|
-
await conn.end()
|
|
289
|
-
} catch {}
|
|
290
|
-
}
|
|
291
|
-
this.queryConnections.clear()
|
|
292
|
-
this.cursorNames.clear()
|
|
293
|
-
|
|
294
|
-
// Close shared connection
|
|
295
|
-
try {
|
|
296
|
-
await this.client?.end()
|
|
297
|
-
} catch {}
|
|
298
|
-
this.client = null
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private stripSubscriptionMetadata(row: Record<string, unknown>): Record<string, unknown> {
|
|
302
|
-
const { op: _op, rw_timestamp: _rwTimestamp, ...data } = row
|
|
303
|
-
return data
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
private normalizeCursor(value: unknown): string | null {
|
|
307
|
-
if (value === null || value === undefined) return null
|
|
308
|
-
const cursor = String(value).trim()
|
|
309
|
-
return cursor === '' ? null : cursor
|
|
310
|
-
}
|
|
311
|
-
}
|