@gokiteam/goki-dev 0.2.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/README.md +478 -0
- package/bin/goki-dev.js +452 -0
- package/bin/mcp-server.js +16 -0
- package/bin/secrets-cli.js +302 -0
- package/cli/ComposeOverrideGenerator.js +226 -0
- package/cli/ComposeParser.js +73 -0
- package/cli/ConfigGenerator.js +304 -0
- package/cli/ConfigManager.js +46 -0
- package/cli/DatabaseManager.js +94 -0
- package/cli/DevToolsChecker.js +21 -0
- package/cli/DevToolsDir.js +66 -0
- package/cli/DevToolsManager.js +451 -0
- package/cli/DockerManager.js +138 -0
- package/cli/FunctionManager.js +95 -0
- package/cli/HttpProxyRewriter.js +91 -0
- package/cli/Logger.js +10 -0
- package/cli/McpConfigManager.js +123 -0
- package/cli/NgrokManager.js +431 -0
- package/cli/ProjectCLI.js +2322 -0
- package/cli/PubSubManager.js +129 -0
- package/cli/SnapshotManager.js +88 -0
- package/cli/UiFormatter.js +292 -0
- package/cli/WebhookUrlRewriter.js +32 -0
- package/cli/secrets/BiometricAuth.js +125 -0
- package/cli/secrets/SecretInjector.js +47 -0
- package/cli/secrets/SecretsConfig.js +141 -0
- package/cli/secrets/SecretsDoctor.js +384 -0
- package/cli/secrets/SecretsManager.js +255 -0
- package/client/dist/client.d.ts +332 -0
- package/client/dist/client.js +507 -0
- package/client/dist/helpers.d.ts +62 -0
- package/client/dist/helpers.js +122 -0
- package/client/dist/index.d.ts +59 -0
- package/client/dist/index.js +78 -0
- package/client/dist/package.json +1 -0
- package/client/dist/types.d.ts +280 -0
- package/client/dist/types.js +7 -0
- package/config.development +46 -0
- package/config.test +18 -0
- package/guidelines/CodingStyleGuideline.md +148 -0
- package/guidelines/CommentingGuideline.md +10 -0
- package/guidelines/HttpApiImplementationGuideline.md +137 -0
- package/guidelines/NamingGuideline.md +182 -0
- package/package.json +138 -0
- package/patterns/api/[collectionName]/Controllers.md +62 -0
- package/patterns/api/[collectionName]/Logic.md +154 -0
- package/patterns/api/[collectionName]/Permissions.md +81 -0
- package/patterns/api/[collectionName]/Router.md +83 -0
- package/patterns/api/[collectionName]/Schemas.md +197 -0
- package/patterns/configs/Patterns.md +7 -0
- package/patterns/enums/Patterns.md +24 -0
- package/patterns/errorHandling/Patterns.md +185 -0
- package/patterns/testing/Patterns.md +232 -0
- package/src/Server.js +238 -0
- package/src/api/dashboard/Controllers.js +9 -0
- package/src/api/dashboard/Logic.js +76 -0
- package/src/api/dashboard/Router.js +11 -0
- package/src/api/dashboard/Schemas.js +47 -0
- package/src/api/data/Controllers.js +26 -0
- package/src/api/data/Logic.js +188 -0
- package/src/api/data/Router.js +16 -0
- package/src/api/docker/Controllers.js +33 -0
- package/src/api/docker/Logic.js +268 -0
- package/src/api/docker/Router.js +15 -0
- package/src/api/docker/Schemas.js +80 -0
- package/src/api/docs/Controllers.js +15 -0
- package/src/api/docs/Logic.js +85 -0
- package/src/api/docs/Router.js +12 -0
- package/src/api/export/Controllers.js +30 -0
- package/src/api/export/Logic.js +143 -0
- package/src/api/export/Router.js +18 -0
- package/src/api/export/Schemas.js +104 -0
- package/src/api/firestore/Controllers.js +152 -0
- package/src/api/firestore/Logic.js +474 -0
- package/src/api/firestore/Router.js +23 -0
- package/src/api/functions/Controllers.js +261 -0
- package/src/api/functions/Logic.js +710 -0
- package/src/api/functions/Router.js +50 -0
- package/src/api/functions/Schemas.js +193 -0
- package/src/api/gateway/Controllers.js +72 -0
- package/src/api/gateway/Logic.js +74 -0
- package/src/api/gateway/Router.js +10 -0
- package/src/api/gateway/Schemas.js +19 -0
- package/src/api/health/Controllers.js +14 -0
- package/src/api/health/Logic.js +24 -0
- package/src/api/health/Router.js +12 -0
- package/src/api/httpTraffic/Controllers.js +29 -0
- package/src/api/httpTraffic/Logic.js +33 -0
- package/src/api/httpTraffic/Router.js +9 -0
- package/src/api/httpTraffic/Schemas.js +23 -0
- package/src/api/logging/Controllers.js +80 -0
- package/src/api/logging/Logic.js +461 -0
- package/src/api/logging/Router.js +24 -0
- package/src/api/logging/Schemas.js +43 -0
- package/src/api/mqtt/Controllers.js +17 -0
- package/src/api/mqtt/Logic.js +66 -0
- package/src/api/mqtt/Router.js +12 -0
- package/src/api/postgres/Controllers.js +97 -0
- package/src/api/postgres/Logic.js +221 -0
- package/src/api/postgres/Router.js +21 -0
- package/src/api/pubsub/Controllers.js +236 -0
- package/src/api/pubsub/Logic.js +732 -0
- package/src/api/pubsub/Router.js +41 -0
- package/src/api/pubsub/Schemas.js +355 -0
- package/src/api/redis/Controllers.js +63 -0
- package/src/api/redis/Logic.js +239 -0
- package/src/api/redis/Router.js +21 -0
- package/src/api/scheduler/Controllers.js +27 -0
- package/src/api/scheduler/Logic.js +49 -0
- package/src/api/scheduler/Router.js +16 -0
- package/src/api/services/Controllers.js +26 -0
- package/src/api/services/Logic.js +205 -0
- package/src/api/services/Router.js +14 -0
- package/src/api/services/Schemas.js +66 -0
- package/src/api/snapshots/Controllers.js +37 -0
- package/src/api/snapshots/Logic.js +797 -0
- package/src/api/snapshots/Router.js +15 -0
- package/src/api/snapshots/Schemas.js +23 -0
- package/src/api/webhooks/Controllers.js +49 -0
- package/src/api/webhooks/Logic.js +137 -0
- package/src/api/webhooks/Router.js +12 -0
- package/src/api/webhooks/Schemas.js +31 -0
- package/src/configs/Application.js +147 -0
- package/src/configs/Default.js +13 -0
- package/src/consumers/BlackboxLogsConsumer.js +235 -0
- package/src/consumers/DockerLogsConsumer.js +687 -0
- package/src/db/Tables.js +66 -0
- package/src/db/schemas/firestore.js +18 -0
- package/src/db/schemas/functions.js +65 -0
- package/src/db/schemas/httpTraffic.js +43 -0
- package/src/db/schemas/logging.js +74 -0
- package/src/db/schemas/migrations.js +64 -0
- package/src/db/schemas/mqtt.js +56 -0
- package/src/db/schemas/pubsub.js +90 -0
- package/src/db/schemas/pubsubRegistry.js +22 -0
- package/src/db/schemas/webhooks.js +28 -0
- package/src/emulation/awsiot/Controllers.js +91 -0
- package/src/emulation/awsiot/Logic.js +70 -0
- package/src/emulation/awsiot/Router.js +19 -0
- package/src/emulation/awsiot/Server.js +100 -0
- package/src/emulation/firestore/Server.js +136 -0
- package/src/emulation/logging/Controllers.js +212 -0
- package/src/emulation/logging/Logic.js +416 -0
- package/src/emulation/logging/Router.js +36 -0
- package/src/emulation/logging/Schemas.js +82 -0
- package/src/emulation/logging/Server.js +108 -0
- package/src/emulation/pubsub/Controllers.js +279 -0
- package/src/emulation/pubsub/DefaultTopics.js +162 -0
- package/src/emulation/pubsub/Logic.js +427 -0
- package/src/emulation/pubsub/README.md +309 -0
- package/src/emulation/pubsub/Router.js +33 -0
- package/src/emulation/pubsub/Server.js +104 -0
- package/src/emulation/pubsub/ShadowPoller.js +276 -0
- package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
- package/src/enums/ContainerNames.js +106 -0
- package/src/enums/ErrorReason.js +28 -0
- package/src/enums/FunctionStatuses.js +15 -0
- package/src/enums/FunctionTriggerTypes.js +15 -0
- package/src/enums/GatewayState.js +7 -0
- package/src/enums/ServiceNames.js +68 -0
- package/src/jobs/DatabaseMaintenance.js +184 -0
- package/src/jobs/MessageHistoryCleanup.js +152 -0
- package/src/mcp/ApiClient.js +25 -0
- package/src/mcp/Server.js +52 -0
- package/src/mcp/prompts/debugging.js +104 -0
- package/src/mcp/resources/platform.js +118 -0
- package/src/mcp/tools/data.js +84 -0
- package/src/mcp/tools/docker.js +166 -0
- package/src/mcp/tools/firestore.js +162 -0
- package/src/mcp/tools/functions.js +380 -0
- package/src/mcp/tools/httpTraffic.js +69 -0
- package/src/mcp/tools/logging.js +174 -0
- package/src/mcp/tools/mqtt.js +37 -0
- package/src/mcp/tools/postgres.js +130 -0
- package/src/mcp/tools/pubsub.js +316 -0
- package/src/mcp/tools/redis.js +146 -0
- package/src/mcp/tools/services.js +169 -0
- package/src/mcp/tools/snapshots.js +88 -0
- package/src/mcp/tools/webhooks.js +115 -0
- package/src/middleware/DevProxy.js +67 -0
- package/src/middleware/ErrorCatcher.js +35 -0
- package/src/middleware/HttpProxy.js +215 -0
- package/src/middleware/Reply.js +24 -0
- package/src/middleware/TraceId.js +9 -0
- package/src/middleware/WebhookProxy.js +234 -0
- package/src/protocols/mqtt/Broker.js +92 -0
- package/src/protocols/mqtt/Handlers.js +175 -0
- package/src/protocols/mqtt/PubSubBridge.js +162 -0
- package/src/protocols/mqtt/Server.js +116 -0
- package/src/runtime/FunctionRunner.js +179 -0
- package/src/services/AppGatewayService.js +582 -0
- package/src/singletons/FirestoreBroadcaster.js +367 -0
- package/src/singletons/FunctionTriggerDispatcher.js +456 -0
- package/src/singletons/FunctionsService.js +418 -0
- package/src/singletons/HttpProxy.js +224 -0
- package/src/singletons/LogBroadcaster.js +159 -0
- package/src/singletons/Logger.js +49 -0
- package/src/singletons/MemoryJsonStore.js +175 -0
- package/src/singletons/MessageBroadcaster.js +190 -0
- package/src/singletons/PostgresBroadcaster.js +367 -0
- package/src/singletons/PostgresClient.js +180 -0
- package/src/singletons/RedisClient.js +184 -0
- package/src/singletons/SqliteStore.js +480 -0
- package/src/singletons/TickService.js +151 -0
- package/src/singletons/WebhookProxy.js +223 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// PostgresBroadcaster singleton for managing SSE connections to PostgreSQL change detection via polling
|
|
2
|
+
// Handles client registration, pg_stat_user_tables polling with reference counting, and change broadcasting
|
|
3
|
+
|
|
4
|
+
import { Logger } from './Logger.js'
|
|
5
|
+
import { PostgresClient } from './PostgresClient.js'
|
|
6
|
+
|
|
7
|
+
class PostgresBroadcasterClass {
|
|
8
|
+
constructor () {
|
|
9
|
+
this.clients = new Map() // connectionId -> { ctx, filters: { database, schema, table } }
|
|
10
|
+
this.watchers = new Map() // watcherKey (database:schema:table) -> { intervalId, lastStats: { inserts, updates, deletes, liveRows }, refCount }
|
|
11
|
+
this.heartbeatId = null
|
|
12
|
+
this.heartbeatIntervalMs = 30000
|
|
13
|
+
this.pollIntervalMs = 2000
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register a new SSE client and start watcher if needed
|
|
18
|
+
* @param {string} connectionId - Unique identifier for the connection (traceId)
|
|
19
|
+
* @param {Object} ctx - Koa context with res.write for SSE
|
|
20
|
+
* @param {Object} filters - { database, schema, table }
|
|
21
|
+
*/
|
|
22
|
+
addClient (connectionId, ctx, filters = {}) {
|
|
23
|
+
this.clients.set(connectionId, { ctx, filters })
|
|
24
|
+
this.startHeartbeat()
|
|
25
|
+
if (filters.database && filters.schema && filters.table) {
|
|
26
|
+
this.startWatcher(filters.database, filters.schema, filters.table)
|
|
27
|
+
}
|
|
28
|
+
Logger.log({
|
|
29
|
+
level: 'info',
|
|
30
|
+
message: 'PostgreSQL SSE client connected',
|
|
31
|
+
data: { connectionId, filters, totalClients: this.clients.size }
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unregister an SSE client and stop watcher if no more clients watching
|
|
37
|
+
* @param {string} connectionId - Unique identifier for the connection
|
|
38
|
+
*/
|
|
39
|
+
removeClient (connectionId) {
|
|
40
|
+
const client = this.clients.get(connectionId)
|
|
41
|
+
if (!client) return
|
|
42
|
+
const removed = this.clients.delete(connectionId)
|
|
43
|
+
if (removed) {
|
|
44
|
+
if (client.filters.database && client.filters.schema && client.filters.table) {
|
|
45
|
+
this.decrementWatcherRefCount(client.filters.database, client.filters.schema, client.filters.table)
|
|
46
|
+
}
|
|
47
|
+
Logger.log({
|
|
48
|
+
level: 'info',
|
|
49
|
+
message: 'PostgreSQL SSE client disconnected',
|
|
50
|
+
data: { connectionId, totalClients: this.clients.size }
|
|
51
|
+
})
|
|
52
|
+
if (this.clients.size === 0) {
|
|
53
|
+
this.stopHeartbeat()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start a polling watcher for a specific table
|
|
60
|
+
* Uses reference counting to share one watcher across multiple clients
|
|
61
|
+
* @param {string} database - PostgreSQL database name
|
|
62
|
+
* @param {string} schema - Schema name
|
|
63
|
+
* @param {string} table - Table name
|
|
64
|
+
*/
|
|
65
|
+
async startWatcher (database, schema, table) {
|
|
66
|
+
const watcherKey = `${database}:${schema}:${table}`
|
|
67
|
+
if (this.watchers.has(watcherKey)) {
|
|
68
|
+
const watcher = this.watchers.get(watcherKey)
|
|
69
|
+
watcher.refCount++
|
|
70
|
+
Logger.log({
|
|
71
|
+
level: 'debug',
|
|
72
|
+
message: 'PostgreSQL watcher ref count incremented',
|
|
73
|
+
data: { database, schema, table, refCount: watcher.refCount }
|
|
74
|
+
})
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const result = await PostgresClient.query(
|
|
79
|
+
'SELECT n_tup_ins, n_tup_upd, n_tup_del, n_live_tup FROM pg_stat_user_tables WHERE schemaname = $1 AND relname = $2',
|
|
80
|
+
[schema, table],
|
|
81
|
+
database
|
|
82
|
+
)
|
|
83
|
+
const lastStats = result.rows.length > 0
|
|
84
|
+
? {
|
|
85
|
+
inserts: parseInt(result.rows[0].n_tup_ins) || 0,
|
|
86
|
+
updates: parseInt(result.rows[0].n_tup_upd) || 0,
|
|
87
|
+
deletes: parseInt(result.rows[0].n_tup_del) || 0,
|
|
88
|
+
liveRows: parseInt(result.rows[0].n_live_tup) || 0
|
|
89
|
+
}
|
|
90
|
+
: { inserts: 0, updates: 0, deletes: 0, liveRows: 0 }
|
|
91
|
+
const intervalId = setInterval(() => this.pollForChanges(watcherKey, database, schema, table), this.pollIntervalMs)
|
|
92
|
+
this.watchers.set(watcherKey, { intervalId, lastStats, refCount: 1 })
|
|
93
|
+
Logger.log({
|
|
94
|
+
level: 'info',
|
|
95
|
+
message: 'PostgreSQL watcher started',
|
|
96
|
+
data: { database, schema, table, lastStats }
|
|
97
|
+
})
|
|
98
|
+
} catch (error) {
|
|
99
|
+
Logger.log({
|
|
100
|
+
level: 'error',
|
|
101
|
+
message: 'Failed to start PostgreSQL watcher',
|
|
102
|
+
data: { database, schema, table, error: error.message }
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decrement watcher reference count and stop if reaches zero
|
|
109
|
+
* @param {string} database - PostgreSQL database name
|
|
110
|
+
* @param {string} schema - Schema name
|
|
111
|
+
* @param {string} table - Table name
|
|
112
|
+
*/
|
|
113
|
+
decrementWatcherRefCount (database, schema, table) {
|
|
114
|
+
const watcherKey = `${database}:${schema}:${table}`
|
|
115
|
+
const watcher = this.watchers.get(watcherKey)
|
|
116
|
+
if (!watcher) return
|
|
117
|
+
watcher.refCount--
|
|
118
|
+
Logger.log({
|
|
119
|
+
level: 'debug',
|
|
120
|
+
message: 'PostgreSQL watcher ref count decremented',
|
|
121
|
+
data: { database, schema, table, refCount: watcher.refCount }
|
|
122
|
+
})
|
|
123
|
+
if (watcher.refCount <= 0) {
|
|
124
|
+
this.stopWatcher(watcherKey)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Stop a polling watcher
|
|
130
|
+
* @param {string} watcherKey - Key in format "database:schema:table"
|
|
131
|
+
*/
|
|
132
|
+
stopWatcher (watcherKey) {
|
|
133
|
+
const watcher = this.watchers.get(watcherKey)
|
|
134
|
+
if (watcher) {
|
|
135
|
+
clearInterval(watcher.intervalId)
|
|
136
|
+
this.watchers.delete(watcherKey)
|
|
137
|
+
Logger.log({
|
|
138
|
+
level: 'info',
|
|
139
|
+
message: 'PostgreSQL watcher stopped',
|
|
140
|
+
data: { watcherKey }
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Poll pg_stat_user_tables for changes and broadcast if detected
|
|
147
|
+
* @param {string} watcherKey - Key in format "database:schema:table"
|
|
148
|
+
* @param {string} database - PostgreSQL database name
|
|
149
|
+
* @param {string} schema - Schema name
|
|
150
|
+
* @param {string} table - Table name
|
|
151
|
+
*/
|
|
152
|
+
async pollForChanges (watcherKey, database, schema, table) {
|
|
153
|
+
const watcher = this.watchers.get(watcherKey)
|
|
154
|
+
if (!watcher) return
|
|
155
|
+
try {
|
|
156
|
+
const result = await PostgresClient.query(
|
|
157
|
+
'SELECT n_tup_ins, n_tup_upd, n_tup_del, n_live_tup FROM pg_stat_user_tables WHERE schemaname = $1 AND relname = $2',
|
|
158
|
+
[schema, table],
|
|
159
|
+
database
|
|
160
|
+
)
|
|
161
|
+
if (result.rows.length === 0) return
|
|
162
|
+
const currentStats = {
|
|
163
|
+
inserts: parseInt(result.rows[0].n_tup_ins) || 0,
|
|
164
|
+
updates: parseInt(result.rows[0].n_tup_upd) || 0,
|
|
165
|
+
deletes: parseInt(result.rows[0].n_tup_del) || 0,
|
|
166
|
+
liveRows: parseInt(result.rows[0].n_live_tup) || 0
|
|
167
|
+
}
|
|
168
|
+
const hasChanged =
|
|
169
|
+
currentStats.inserts !== watcher.lastStats.inserts ||
|
|
170
|
+
currentStats.updates !== watcher.lastStats.updates ||
|
|
171
|
+
currentStats.deletes !== watcher.lastStats.deletes ||
|
|
172
|
+
currentStats.liveRows !== watcher.lastStats.liveRows
|
|
173
|
+
if (hasChanged) {
|
|
174
|
+
const change = {
|
|
175
|
+
eventId: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
database,
|
|
178
|
+
schema,
|
|
179
|
+
table,
|
|
180
|
+
changeType: 'data_changed',
|
|
181
|
+
stats: currentStats
|
|
182
|
+
}
|
|
183
|
+
Logger.log({
|
|
184
|
+
level: 'info',
|
|
185
|
+
message: 'PostgreSQL change detected',
|
|
186
|
+
data: { watcherKey, previousStats: watcher.lastStats, currentStats }
|
|
187
|
+
})
|
|
188
|
+
watcher.lastStats = currentStats
|
|
189
|
+
this.broadcast(change)
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
Logger.log({
|
|
193
|
+
level: 'error',
|
|
194
|
+
message: 'PostgreSQL poll error',
|
|
195
|
+
data: { watcherKey, error: error.message }
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Broadcast a change event to all connected clients matching filters
|
|
202
|
+
* @param {Object} change - { eventId, timestamp, database, schema, table, changeType, stats }
|
|
203
|
+
*/
|
|
204
|
+
broadcast (change) {
|
|
205
|
+
if (this.clients.size === 0) return
|
|
206
|
+
let sentCount = 0
|
|
207
|
+
for (const [connectionId, { ctx, filters }] of this.clients) {
|
|
208
|
+
if (this.matchesFilters(change, filters)) {
|
|
209
|
+
try {
|
|
210
|
+
this.sendSSE(ctx, 'change', change)
|
|
211
|
+
sentCount++
|
|
212
|
+
} catch (error) {
|
|
213
|
+
Logger.log({
|
|
214
|
+
level: 'error',
|
|
215
|
+
message: 'Failed to send SSE change event to client',
|
|
216
|
+
data: { connectionId, error: error.message }
|
|
217
|
+
})
|
|
218
|
+
this.removeClient(connectionId)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
Logger.log({
|
|
223
|
+
level: 'debug',
|
|
224
|
+
message: 'PostgreSQL change broadcasted to SSE clients',
|
|
225
|
+
data: {
|
|
226
|
+
eventId: change.eventId,
|
|
227
|
+
changeType: change.changeType,
|
|
228
|
+
table: change.table,
|
|
229
|
+
totalClients: this.clients.size,
|
|
230
|
+
sentToClients: sentCount
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Check if change matches client filters
|
|
237
|
+
* @param {Object} change - The change event to check
|
|
238
|
+
* @param {Object} filters - { database?, schema?, table? }
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
matchesFilters (change, filters) {
|
|
242
|
+
if (!filters || Object.keys(filters).length === 0) {
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
if (filters.database && change.database !== filters.database) {
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
if (filters.schema && change.schema !== filters.schema) {
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
if (filters.table && change.table !== filters.table) {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Send SSE formatted message to client
|
|
259
|
+
* @param {Object} ctx - Koa context
|
|
260
|
+
* @param {string} event - Event name
|
|
261
|
+
* @param {Object} data - Data to send
|
|
262
|
+
*/
|
|
263
|
+
sendSSE (ctx, event, data) {
|
|
264
|
+
ctx.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Start the shared heartbeat interval (one for all clients)
|
|
269
|
+
*/
|
|
270
|
+
startHeartbeat () {
|
|
271
|
+
if (this.heartbeatId) return
|
|
272
|
+
this.heartbeatId = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Stop the shared heartbeat interval when no clients remain
|
|
277
|
+
*/
|
|
278
|
+
stopHeartbeat () {
|
|
279
|
+
if (this.heartbeatId) {
|
|
280
|
+
clearInterval(this.heartbeatId)
|
|
281
|
+
this.heartbeatId = null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Send heartbeat to all connected clients
|
|
287
|
+
* Used to keep connections alive
|
|
288
|
+
*/
|
|
289
|
+
sendHeartbeat () {
|
|
290
|
+
for (const [connectionId, { ctx }] of this.clients) {
|
|
291
|
+
try {
|
|
292
|
+
ctx.res.write(': heartbeat\n\n')
|
|
293
|
+
} catch (error) {
|
|
294
|
+
Logger.log({
|
|
295
|
+
level: 'error',
|
|
296
|
+
message: 'Failed to send heartbeat to PostgreSQL SSE client',
|
|
297
|
+
data: { connectionId, error: error.message }
|
|
298
|
+
})
|
|
299
|
+
this.removeClient(connectionId)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Notify all clients watching a specific database that data has changed
|
|
306
|
+
* Called after write queries (INSERT/UPDATE/DELETE) to trigger immediate refresh
|
|
307
|
+
* @param {string} database - PostgreSQL database name
|
|
308
|
+
* @param {string} schema - Schema name (optional, broadcasts to all schemas if omitted)
|
|
309
|
+
* @param {string} table - Table name (optional, broadcasts to all tables if omitted)
|
|
310
|
+
*/
|
|
311
|
+
notifyChange (database, schema, table) {
|
|
312
|
+
if (this.clients.size === 0) return
|
|
313
|
+
const change = {
|
|
314
|
+
eventId: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
315
|
+
timestamp: new Date().toISOString(),
|
|
316
|
+
database,
|
|
317
|
+
schema: schema || '*',
|
|
318
|
+
table: table || '*',
|
|
319
|
+
changeType: 'data_changed',
|
|
320
|
+
stats: null
|
|
321
|
+
}
|
|
322
|
+
// Broadcast to all clients watching this database
|
|
323
|
+
let sentCount = 0
|
|
324
|
+
for (const [connectionId, { ctx, filters }] of this.clients) {
|
|
325
|
+
if (filters.database && filters.database !== database) continue
|
|
326
|
+
if (schema && filters.schema && filters.schema !== schema) continue
|
|
327
|
+
if (table && filters.table && filters.table !== table) continue
|
|
328
|
+
try {
|
|
329
|
+
this.sendSSE(ctx, 'change', change)
|
|
330
|
+
sentCount++
|
|
331
|
+
} catch (error) {
|
|
332
|
+
this.removeClient(connectionId)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (sentCount > 0) {
|
|
336
|
+
Logger.log({
|
|
337
|
+
level: 'info',
|
|
338
|
+
message: 'PostgreSQL change notification sent',
|
|
339
|
+
data: { database, schema, table, sentToClients: sentCount }
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get current client and watcher statistics
|
|
346
|
+
* @returns {Object}
|
|
347
|
+
*/
|
|
348
|
+
getStats () {
|
|
349
|
+
const stats = {
|
|
350
|
+
totalClients: this.clients.size,
|
|
351
|
+
totalWatchers: this.watchers.size,
|
|
352
|
+
clientsByTable: {},
|
|
353
|
+
watcherRefCounts: {}
|
|
354
|
+
}
|
|
355
|
+
for (const [, { filters }] of this.clients) {
|
|
356
|
+
const key = `${filters.database}:${filters.schema}:${filters.table}`
|
|
357
|
+
stats.clientsByTable[key] = (stats.clientsByTable[key] || 0) + 1
|
|
358
|
+
}
|
|
359
|
+
for (const [watcherKey, { refCount }] of this.watchers) {
|
|
360
|
+
stats.watcherRefCounts[watcherKey] = refCount
|
|
361
|
+
}
|
|
362
|
+
return stats
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Export singleton instance
|
|
367
|
+
export const PostgresBroadcaster = new PostgresBroadcasterClass()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import pg from 'pg'
|
|
2
|
+
import { Application } from '../configs/Application.js'
|
|
3
|
+
|
|
4
|
+
const { Pool } = pg
|
|
5
|
+
|
|
6
|
+
class PostgresClientClass {
|
|
7
|
+
constructor () {
|
|
8
|
+
this.pools = new Map() // Map of database -> pool
|
|
9
|
+
this.defaultDatabase = null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
initialize () {
|
|
13
|
+
// Pools are created lazily on first use
|
|
14
|
+
// This method exists for API compatibility with Server.js
|
|
15
|
+
const config = Application.postgres
|
|
16
|
+
this.defaultDatabase = config.database
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getPool (database = null) {
|
|
20
|
+
const config = Application.postgres
|
|
21
|
+
const dbName = database || config.database
|
|
22
|
+
if (!this.pools.has(dbName)) {
|
|
23
|
+
const pool = new Pool({
|
|
24
|
+
host: config.host,
|
|
25
|
+
port: config.port,
|
|
26
|
+
user: config.user,
|
|
27
|
+
password: config.password,
|
|
28
|
+
database: dbName,
|
|
29
|
+
max: 5,
|
|
30
|
+
idleTimeoutMillis: 30000,
|
|
31
|
+
connectionTimeoutMillis: 5000
|
|
32
|
+
})
|
|
33
|
+
pool.on('error', (err) => {
|
|
34
|
+
console.error(`PostgreSQL pool error (${dbName}):`, err.message)
|
|
35
|
+
})
|
|
36
|
+
this.pools.set(dbName, pool)
|
|
37
|
+
}
|
|
38
|
+
return this.pools.get(dbName)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async query (text, params = [], database = null) {
|
|
42
|
+
const pool = this.getPool(database)
|
|
43
|
+
const client = await pool.connect()
|
|
44
|
+
try {
|
|
45
|
+
const result = await client.query(text, params)
|
|
46
|
+
return result
|
|
47
|
+
} finally {
|
|
48
|
+
client.release()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async testConnection (database = null) {
|
|
53
|
+
try {
|
|
54
|
+
const result = await this.query('SELECT NOW() as time, current_database() as database', [], database)
|
|
55
|
+
return { connected: true, time: result.rows[0].time, database: result.rows[0].database }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { connected: false, error: error.message }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async getSchemas (database = null) {
|
|
62
|
+
const result = await this.query(`
|
|
63
|
+
SELECT schema_name
|
|
64
|
+
FROM information_schema.schemata
|
|
65
|
+
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
|
66
|
+
ORDER BY schema_name
|
|
67
|
+
`, [], database)
|
|
68
|
+
return result.rows.map(row => row.schema_name)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getTables (schema = 'public', database = null) {
|
|
72
|
+
const result = await this.query(`
|
|
73
|
+
SELECT
|
|
74
|
+
t.table_name,
|
|
75
|
+
(SELECT COUNT(*) FROM information_schema.columns c
|
|
76
|
+
WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name) as column_count
|
|
77
|
+
FROM information_schema.tables t
|
|
78
|
+
WHERE t.table_schema = $1
|
|
79
|
+
AND t.table_type = 'BASE TABLE'
|
|
80
|
+
ORDER BY t.table_name
|
|
81
|
+
`, [schema], database)
|
|
82
|
+
const tables = result.rows.map(row => ({
|
|
83
|
+
name: row.table_name,
|
|
84
|
+
columnCount: parseInt(row.column_count) || 0,
|
|
85
|
+
rowCount: 0
|
|
86
|
+
}))
|
|
87
|
+
// Fetch exact row counts for each table (dev environment, tables are small)
|
|
88
|
+
const countPromises = tables.map(async (table) => {
|
|
89
|
+
try {
|
|
90
|
+
const countResult = await this.query(
|
|
91
|
+
`SELECT COUNT(*) as count FROM "${schema}"."${table.name}"`,
|
|
92
|
+
[], database
|
|
93
|
+
)
|
|
94
|
+
table.rowCount = parseInt(countResult.rows[0].count) || 0
|
|
95
|
+
} catch (error) {
|
|
96
|
+
table.rowCount = 0
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
await Promise.all(countPromises)
|
|
100
|
+
return tables
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async getColumns (schema, table, database = null) {
|
|
104
|
+
const result = await this.query(`
|
|
105
|
+
SELECT
|
|
106
|
+
column_name,
|
|
107
|
+
data_type,
|
|
108
|
+
is_nullable,
|
|
109
|
+
column_default,
|
|
110
|
+
character_maximum_length,
|
|
111
|
+
numeric_precision,
|
|
112
|
+
numeric_scale
|
|
113
|
+
FROM information_schema.columns
|
|
114
|
+
WHERE table_schema = $1 AND table_name = $2
|
|
115
|
+
ORDER BY ordinal_position
|
|
116
|
+
`, [schema, table], database)
|
|
117
|
+
return result.rows.map(row => ({
|
|
118
|
+
name: row.column_name,
|
|
119
|
+
type: row.data_type,
|
|
120
|
+
nullable: row.is_nullable === 'YES',
|
|
121
|
+
default: row.column_default,
|
|
122
|
+
maxLength: row.character_maximum_length,
|
|
123
|
+
precision: row.numeric_precision,
|
|
124
|
+
scale: row.numeric_scale
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getRows (schema, table, limit = 50, offset = 0, database = null) {
|
|
129
|
+
const countResult = await this.query(
|
|
130
|
+
`SELECT COUNT(*) as total FROM "${schema}"."${table}"`,
|
|
131
|
+
[],
|
|
132
|
+
database
|
|
133
|
+
)
|
|
134
|
+
const total = parseInt(countResult.rows[0].total)
|
|
135
|
+
const result = await this.query(
|
|
136
|
+
`SELECT * FROM "${schema}"."${table}" LIMIT $1 OFFSET $2`,
|
|
137
|
+
[limit, offset],
|
|
138
|
+
database
|
|
139
|
+
)
|
|
140
|
+
return {
|
|
141
|
+
rows: result.rows,
|
|
142
|
+
total,
|
|
143
|
+
fields: result.fields.map(f => ({ name: f.name, dataTypeID: f.dataTypeID }))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async executeQuery (sql, database = null) {
|
|
148
|
+
const trimmedSql = sql.trim().toUpperCase()
|
|
149
|
+
const allowedStatements = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'WITH']
|
|
150
|
+
const firstWord = trimmedSql.split(/\s/)[0]
|
|
151
|
+
if (!allowedStatements.includes(firstWord)) {
|
|
152
|
+
throw new Error('Only SELECT, INSERT, UPDATE, and DELETE queries are allowed')
|
|
153
|
+
}
|
|
154
|
+
const result = await this.query(sql, [], database)
|
|
155
|
+
return {
|
|
156
|
+
rows: result.rows || [],
|
|
157
|
+
rowCount: result.rowCount,
|
|
158
|
+
fields: result.fields ? result.fields.map(f => ({ name: f.name, dataTypeID: f.dataTypeID })) : []
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getDatabases () {
|
|
163
|
+
const result = await this.query(`
|
|
164
|
+
SELECT datname as name
|
|
165
|
+
FROM pg_database
|
|
166
|
+
WHERE datistemplate = false
|
|
167
|
+
ORDER BY datname
|
|
168
|
+
`)
|
|
169
|
+
return result.rows.map(row => row.name)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async shutdown () {
|
|
173
|
+
for (const [dbName, pool] of this.pools) {
|
|
174
|
+
await pool.end()
|
|
175
|
+
}
|
|
176
|
+
this.pools.clear()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const PostgresClient = new PostgresClientClass()
|