@risingwave/wavelet-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -0
- package/src/__tests__/cursor-parsing.test.ts +68 -0
- package/src/__tests__/http-api.test.ts +130 -0
- package/src/__tests__/integration.test.ts +217 -0
- package/src/__tests__/jwt.test.ts +62 -0
- package/src/config-loader.ts +20 -0
- package/src/cursor-manager.ts +209 -0
- package/src/ddl-manager.ts +307 -0
- package/src/http-api.ts +199 -0
- package/src/index.ts +31 -0
- package/src/jwt.ts +56 -0
- package/src/server.ts +82 -0
- package/src/ws-fanout.ts +159 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import pg from 'pg'
|
|
2
|
+
import type { SqlFragment, ViewDef } 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
|
+
type DiffCallback = (viewName: string, diff: ViewDiff) => void
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Manages persistent subscription cursors against RisingWave.
|
|
23
|
+
*
|
|
24
|
+
* Each view gets its own dedicated pg connection and a persistent cursor.
|
|
25
|
+
* Uses blocking FETCH (WITH timeout) so there is no polling interval -
|
|
26
|
+
* diffs are dispatched as soon as RisingWave produces them.
|
|
27
|
+
*/
|
|
28
|
+
export class CursorManager {
|
|
29
|
+
// Shared connection for DDL (CREATE SUBSCRIPTION) and ad-hoc queries
|
|
30
|
+
private client: InstanceType<typeof Client> | null = null
|
|
31
|
+
|
|
32
|
+
// Per-view dedicated connections for blocking FETCH
|
|
33
|
+
private viewConnections: Map<string, InstanceType<typeof Client>> = new Map()
|
|
34
|
+
private cursorNames: Map<string, string> = new Map()
|
|
35
|
+
private subscriptions: Map<string, string> = new Map()
|
|
36
|
+
private running = false
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private connectionString: string,
|
|
40
|
+
private views: Record<string, ViewDef | SqlFragment>
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
async initialize(): Promise<void> {
|
|
44
|
+
this.client = new Client({ connectionString: this.connectionString })
|
|
45
|
+
await this.client.connect()
|
|
46
|
+
console.log('Connected to RisingWave')
|
|
47
|
+
|
|
48
|
+
for (const [viewName] of Object.entries(this.views)) {
|
|
49
|
+
const subName = `wavelet_sub_${viewName}`
|
|
50
|
+
|
|
51
|
+
// Create subscription if not exists (idempotent)
|
|
52
|
+
try {
|
|
53
|
+
await this.client.query(
|
|
54
|
+
`CREATE SUBSCRIPTION ${subName} FROM ${viewName} WITH (retention = '24h')`
|
|
55
|
+
)
|
|
56
|
+
console.log(`Created subscription: ${subName}`)
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
if (err.message?.includes('exists')) {
|
|
59
|
+
console.log(`Subscription exists: ${subName}`)
|
|
60
|
+
} else {
|
|
61
|
+
throw err
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.subscriptions.set(viewName, subName)
|
|
66
|
+
|
|
67
|
+
// Create dedicated connection and persistent cursor for this view
|
|
68
|
+
const conn = new Client({ connectionString: this.connectionString })
|
|
69
|
+
await conn.connect()
|
|
70
|
+
|
|
71
|
+
const cursorName = `wavelet_cur_${viewName}`
|
|
72
|
+
await conn.query(`DECLARE ${cursorName} SUBSCRIPTION CURSOR FOR ${subName}`)
|
|
73
|
+
console.log(`Opened persistent cursor: ${cursorName}`)
|
|
74
|
+
|
|
75
|
+
this.viewConnections.set(viewName, conn)
|
|
76
|
+
this.cursorNames.set(viewName, cursorName)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Start listening for diffs on all views.
|
|
82
|
+
* Each view runs its own async loop with blocking FETCH.
|
|
83
|
+
* No polling interval - FETCH blocks until data arrives or timeout.
|
|
84
|
+
*/
|
|
85
|
+
startPolling(callback: DiffCallback): void {
|
|
86
|
+
this.running = true
|
|
87
|
+
|
|
88
|
+
for (const [viewName] of this.subscriptions.entries()) {
|
|
89
|
+
this.listenLoop(viewName, callback)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stopPolling(): void {
|
|
94
|
+
this.running = false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async listenLoop(viewName: string, callback: DiffCallback): Promise<void> {
|
|
98
|
+
const conn = this.viewConnections.get(viewName)
|
|
99
|
+
const cursorName = this.cursorNames.get(viewName)
|
|
100
|
+
if (!conn || !cursorName) return
|
|
101
|
+
|
|
102
|
+
while (this.running) {
|
|
103
|
+
try {
|
|
104
|
+
// Blocking FETCH: waits up to 5s for new data, returns immediately when data arrives
|
|
105
|
+
const result = await conn.query(
|
|
106
|
+
`FETCH NEXT FROM ${cursorName} WITH (timeout = '5s')`
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (result.rows.length === 0) continue
|
|
110
|
+
|
|
111
|
+
// Got at least one row. Drain any remaining rows without blocking.
|
|
112
|
+
const allRows = [...result.rows]
|
|
113
|
+
let more = true
|
|
114
|
+
while (more) {
|
|
115
|
+
const batch = await conn.query(
|
|
116
|
+
`FETCH 100 FROM ${cursorName}`
|
|
117
|
+
)
|
|
118
|
+
if (batch.rows.length > 0) {
|
|
119
|
+
allRows.push(...batch.rows)
|
|
120
|
+
} else {
|
|
121
|
+
more = false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diff = this.parseDiffs(allRows)
|
|
126
|
+
|
|
127
|
+
if (diff.inserted.length > 0 || diff.updated.length > 0 || diff.deleted.length > 0) {
|
|
128
|
+
callback(viewName, diff)
|
|
129
|
+
}
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
if (!this.running) return
|
|
132
|
+
console.error(`[cursor-manager] Error fetching ${viewName}:`, err.message)
|
|
133
|
+
// Back off on error, then retry
|
|
134
|
+
await new Promise(r => setTimeout(r, 1000))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
parseDiffs(rows: any[]): ViewDiff {
|
|
140
|
+
const diff: ViewDiff = {
|
|
141
|
+
cursor: '',
|
|
142
|
+
inserted: [],
|
|
143
|
+
updated: [],
|
|
144
|
+
deleted: [],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const { op, rw_timestamp, ...data } = row
|
|
149
|
+
diff.cursor = rw_timestamp ?? diff.cursor
|
|
150
|
+
|
|
151
|
+
// RisingWave returns op as a string: "Insert", "Delete", "UpdateInsert", "UpdateDelete"
|
|
152
|
+
const opStr = String(op)
|
|
153
|
+
switch (opStr) {
|
|
154
|
+
case 'Insert':
|
|
155
|
+
case '1':
|
|
156
|
+
diff.inserted.push(data)
|
|
157
|
+
break
|
|
158
|
+
case 'Delete':
|
|
159
|
+
case '2':
|
|
160
|
+
diff.deleted.push(data)
|
|
161
|
+
break
|
|
162
|
+
case 'UpdateDelete':
|
|
163
|
+
case '3':
|
|
164
|
+
diff.deleted.push(data)
|
|
165
|
+
break
|
|
166
|
+
case 'UpdateInsert':
|
|
167
|
+
case '4':
|
|
168
|
+
diff.updated.push(data)
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return diff
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async query(sql: string): Promise<any[]> {
|
|
177
|
+
if (!this.client) throw new Error('Not connected')
|
|
178
|
+
const result = await this.client.query(sql)
|
|
179
|
+
return result.rows
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async execute(sql: string): Promise<void> {
|
|
183
|
+
if (!this.client) throw new Error('Not connected')
|
|
184
|
+
await this.client.query(sql)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async close(): Promise<void> {
|
|
188
|
+
this.running = false
|
|
189
|
+
|
|
190
|
+
// Close per-view connections
|
|
191
|
+
for (const [viewName, conn] of this.viewConnections) {
|
|
192
|
+
const cursorName = this.cursorNames.get(viewName)
|
|
193
|
+
try {
|
|
194
|
+
if (cursorName) await conn.query(`CLOSE ${cursorName}`)
|
|
195
|
+
} catch {}
|
|
196
|
+
try {
|
|
197
|
+
await conn.end()
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
this.viewConnections.clear()
|
|
201
|
+
this.cursorNames.clear()
|
|
202
|
+
|
|
203
|
+
// Close shared connection
|
|
204
|
+
try {
|
|
205
|
+
await this.client?.end()
|
|
206
|
+
} catch {}
|
|
207
|
+
this.client = null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import pg from 'pg'
|
|
2
|
+
import type { WaveletConfig, StreamDef, ViewDef, SqlFragment } from '@risingwave/wavelet'
|
|
3
|
+
|
|
4
|
+
const { Client } = pg
|
|
5
|
+
|
|
6
|
+
export interface DdlAction {
|
|
7
|
+
type: 'create' | 'update' | 'delete' | 'unchanged'
|
|
8
|
+
resource: 'stream' | 'view' | '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 getViewQuery(viewDef: ViewDef | SqlFragment): string {
|
|
30
|
+
if ('_tag' in viewDef && viewDef._tag === 'sql') return viewDef.text
|
|
31
|
+
return (viewDef as ViewDef).query.text
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildCreateTableSql(name: string, streamDef: StreamDef): string {
|
|
35
|
+
const cols = Object.entries(streamDef.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 streams, views, 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 existingViews = await this.getExistingViews()
|
|
75
|
+
const existingSubscriptions = await this.getExistingSubscriptions()
|
|
76
|
+
|
|
77
|
+
const desiredStreams = config.streams ?? {}
|
|
78
|
+
const desiredViews = config.views ?? {}
|
|
79
|
+
|
|
80
|
+
// 2. Determine which streams (tables) to create or remove
|
|
81
|
+
const desiredStreamNames = new Set(Object.keys(desiredStreams))
|
|
82
|
+
const desiredViewNames = new Set(Object.keys(desiredViews))
|
|
83
|
+
|
|
84
|
+
// 3. Sync streams - create missing tables
|
|
85
|
+
for (const [streamName, streamDef] of Object.entries(desiredStreams)) {
|
|
86
|
+
if (existingTables.has(streamName)) {
|
|
87
|
+
actions.push({ type: 'unchanged', resource: 'stream', name: streamName })
|
|
88
|
+
} else {
|
|
89
|
+
await this.createTable(streamName, streamDef)
|
|
90
|
+
actions.push({ type: 'create', resource: 'stream', name: streamName })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. Sync views - create, update, or leave unchanged
|
|
95
|
+
for (const [viewName, viewDef] of Object.entries(desiredViews)) {
|
|
96
|
+
const subName = `wavelet_sub_${viewName}`
|
|
97
|
+
const desiredSql = getViewQuery(viewDef)
|
|
98
|
+
const existingSql = existingViews.get(viewName)
|
|
99
|
+
|
|
100
|
+
if (existingSql === undefined) {
|
|
101
|
+
// View does not exist - create MV and subscription
|
|
102
|
+
await this.createMaterializedView(viewName, desiredSql)
|
|
103
|
+
actions.push({ type: 'create', resource: 'view', name: viewName })
|
|
104
|
+
|
|
105
|
+
await this.createSubscription(subName, viewName)
|
|
106
|
+
actions.push({ type: 'create', resource: 'subscription', name: subName })
|
|
107
|
+
} else if (normalizeSql(existingSql) !== normalizeSql(desiredSql)) {
|
|
108
|
+
// View SQL changed - drop subscription, drop MV, recreate
|
|
109
|
+
if (existingSubscriptions.has(subName)) {
|
|
110
|
+
await this.dropSubscription(subName)
|
|
111
|
+
actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'dropped for view update' })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await this.dropMaterializedView(viewName)
|
|
115
|
+
actions.push({ type: 'delete', resource: 'view', name: viewName, detail: 'dropped for update' })
|
|
116
|
+
|
|
117
|
+
await this.createMaterializedView(viewName, desiredSql)
|
|
118
|
+
actions.push({ type: 'create', resource: 'view', name: viewName, detail: 'recreated with updated SQL' })
|
|
119
|
+
|
|
120
|
+
await this.createSubscription(subName, viewName)
|
|
121
|
+
actions.push({ type: 'create', resource: 'subscription', name: subName, detail: 'recreated after view update' })
|
|
122
|
+
} else {
|
|
123
|
+
// View SQL unchanged
|
|
124
|
+
actions.push({ type: 'unchanged', resource: 'view', name: viewName })
|
|
125
|
+
|
|
126
|
+
// Ensure subscription exists even if the view is unchanged
|
|
127
|
+
if (!existingSubscriptions.has(subName)) {
|
|
128
|
+
await this.createSubscription(subName, viewName)
|
|
129
|
+
actions.push({ type: 'create', resource: 'subscription', name: subName })
|
|
130
|
+
} else {
|
|
131
|
+
actions.push({ type: 'unchanged', resource: 'subscription', name: subName })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 5. Remove views that are no longer in the config
|
|
137
|
+
for (const [existingViewName] of existingViews) {
|
|
138
|
+
if (!desiredViewNames.has(existingViewName)) {
|
|
139
|
+
const subName = `wavelet_sub_${existingViewName}`
|
|
140
|
+
|
|
141
|
+
// Drop subscription first
|
|
142
|
+
if (existingSubscriptions.has(subName)) {
|
|
143
|
+
await this.dropSubscription(subName)
|
|
144
|
+
actions.push({ type: 'delete', resource: 'subscription', name: subName, detail: 'view removed from config' })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await this.dropMaterializedView(existingViewName)
|
|
148
|
+
actions.push({ type: 'delete', resource: 'view', name: existingViewName, detail: 'removed from config' })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 6. Remove orphaned subscriptions that are no longer needed
|
|
153
|
+
for (const existingSubName of existingSubscriptions) {
|
|
154
|
+
// Only manage wavelet-prefixed subscriptions
|
|
155
|
+
if (!existingSubName.startsWith('wavelet_sub_')) continue
|
|
156
|
+
|
|
157
|
+
const viewName = existingSubName.slice('wavelet_sub_'.length)
|
|
158
|
+
if (!desiredViewNames.has(viewName)) {
|
|
159
|
+
// Already handled in step 5 if the view existed, but handle dangling subs too
|
|
160
|
+
if (!existingViews.has(viewName)) {
|
|
161
|
+
await this.dropSubscription(existingSubName)
|
|
162
|
+
actions.push({ type: 'delete', resource: 'subscription', name: existingSubName, detail: 'orphaned subscription' })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 7. Remove streams (tables) that are no longer in the config
|
|
168
|
+
for (const existingTableName of existingTables) {
|
|
169
|
+
if (!desiredStreamNames.has(existingTableName)) {
|
|
170
|
+
// Only drop if no MV depends on it
|
|
171
|
+
const hasDependents = await this.tableHasDependentViews(existingTableName)
|
|
172
|
+
if (hasDependents) {
|
|
173
|
+
console.log(`[ddl-manager] Skipping drop of table "${existingTableName}" - materialized views depend on it`)
|
|
174
|
+
actions.push({
|
|
175
|
+
type: 'unchanged',
|
|
176
|
+
resource: 'stream',
|
|
177
|
+
name: existingTableName,
|
|
178
|
+
detail: 'kept because dependent views exist',
|
|
179
|
+
})
|
|
180
|
+
} else {
|
|
181
|
+
await this.dropTable(existingTableName)
|
|
182
|
+
actions.push({ type: 'delete', resource: 'stream', name: existingTableName, detail: 'removed from config' })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return actions
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Query helpers ─────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
private async getExistingTables(): Promise<Set<string>> {
|
|
193
|
+
const result = await this.client!.query(
|
|
194
|
+
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`
|
|
195
|
+
)
|
|
196
|
+
return new Set(result.rows.map((r: any) => r.table_name))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async getExistingViews(): Promise<Map<string, string>> {
|
|
200
|
+
const result = await this.client!.query(
|
|
201
|
+
`SELECT name, definition FROM rw_catalog.rw_materialized_views WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public')`
|
|
202
|
+
)
|
|
203
|
+
const views = new Map<string, string>()
|
|
204
|
+
for (const row of result.rows) {
|
|
205
|
+
views.set(row.name, row.definition)
|
|
206
|
+
}
|
|
207
|
+
return views
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async getExistingSubscriptions(): Promise<Set<string>> {
|
|
211
|
+
const result = await this.client!.query(
|
|
212
|
+
`SELECT name FROM rw_catalog.rw_subscriptions WHERE schema_id = (SELECT id FROM rw_catalog.rw_schemas WHERE name = 'public')`
|
|
213
|
+
)
|
|
214
|
+
return new Set(result.rows.map((r: any) => r.name))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async tableHasDependentViews(tableName: string): Promise<boolean> {
|
|
218
|
+
const result = await this.client!.query(
|
|
219
|
+
`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`,
|
|
220
|
+
[`%${tableName}%`]
|
|
221
|
+
)
|
|
222
|
+
return result.rows.length > 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── DDL operations ────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
private async createTable(name: string, streamDef: StreamDef): Promise<void> {
|
|
228
|
+
const sql = buildCreateTableSql(name, streamDef)
|
|
229
|
+
try {
|
|
230
|
+
await this.client!.query(sql)
|
|
231
|
+
console.log(`[ddl-manager] Created table: ${name}`)
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
if (err.message?.includes('already exists')) {
|
|
234
|
+
console.log(`[ddl-manager] Table already exists: ${name}`)
|
|
235
|
+
} else {
|
|
236
|
+
throw err
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async dropTable(name: string): Promise<void> {
|
|
242
|
+
try {
|
|
243
|
+
await this.client!.query(`DROP TABLE ${name}`)
|
|
244
|
+
console.log(`[ddl-manager] Dropped table: ${name}`)
|
|
245
|
+
} catch (err: any) {
|
|
246
|
+
if (err.message?.includes('does not exist')) {
|
|
247
|
+
console.log(`[ddl-manager] Table already gone: ${name}`)
|
|
248
|
+
} else {
|
|
249
|
+
throw err
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async createMaterializedView(name: string, sql: string): Promise<void> {
|
|
255
|
+
try {
|
|
256
|
+
await this.client!.query(`CREATE MATERIALIZED VIEW ${name} AS ${sql}`)
|
|
257
|
+
console.log(`[ddl-manager] Created materialized view: ${name}`)
|
|
258
|
+
} catch (err: any) {
|
|
259
|
+
if (err.message?.includes('already exists')) {
|
|
260
|
+
console.log(`[ddl-manager] Materialized view already exists: ${name}`)
|
|
261
|
+
} else {
|
|
262
|
+
throw err
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async dropMaterializedView(name: string): Promise<void> {
|
|
268
|
+
try {
|
|
269
|
+
await this.client!.query(`DROP MATERIALIZED VIEW ${name}`)
|
|
270
|
+
console.log(`[ddl-manager] Dropped materialized view: ${name}`)
|
|
271
|
+
} catch (err: any) {
|
|
272
|
+
if (err.message?.includes('does not exist')) {
|
|
273
|
+
console.log(`[ddl-manager] Materialized view already gone: ${name}`)
|
|
274
|
+
} else {
|
|
275
|
+
throw err
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async createSubscription(subName: string, viewName: string): Promise<void> {
|
|
281
|
+
try {
|
|
282
|
+
await this.client!.query(
|
|
283
|
+
`CREATE SUBSCRIPTION ${subName} FROM ${viewName} WITH (retention = '24h')`
|
|
284
|
+
)
|
|
285
|
+
console.log(`[ddl-manager] Created subscription: ${subName}`)
|
|
286
|
+
} catch (err: any) {
|
|
287
|
+
if (err.message?.includes('already exists')) {
|
|
288
|
+
console.log(`[ddl-manager] Subscription already exists: ${subName}`)
|
|
289
|
+
} else {
|
|
290
|
+
throw err
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async dropSubscription(subName: string): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
await this.client!.query(`DROP SUBSCRIPTION ${subName}`)
|
|
298
|
+
console.log(`[ddl-manager] Dropped subscription: ${subName}`)
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
if (err.message?.includes('does not exist')) {
|
|
301
|
+
console.log(`[ddl-manager] Subscription already gone: ${subName}`)
|
|
302
|
+
} else {
|
|
303
|
+
throw err
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|