go-duck-cli 1.0.9 → 1.1.12
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 +30 -15
- package/generators/ai_docs.js +130 -0
- package/generators/broker.js +63 -0
- package/generators/config.js +149 -7
- package/generators/devops.js +210 -43
- package/generators/docs.js +23 -4
- package/generators/elasticsearch.js +263 -0
- package/generators/kratos.js +229 -41
- package/generators/metering.js +280 -48
- package/generators/migrations.js +92 -198
- package/generators/mqtt.js +2 -39
- package/generators/multitenancy.js +274 -71
- package/generators/nats.js +39 -0
- package/generators/outbox.js +171 -0
- package/generators/postgrest.js +7 -3
- package/generators/postman.js +405 -0
- package/generators/repository.js +27 -0
- package/generators/router.js +27 -0
- package/generators/security.js +95 -14
- package/generators/serverless.js +147 -0
- package/generators/storage.js +589 -0
- package/generators/swagger.js +84 -60
- package/generators/telemetry.js +23 -32
- package/generators/websocket.js +55 -21
- package/index.js +493 -116
- package/package.json +6 -4
- package/parser/gdl.js +163 -24
- package/templates/docs/index.html.hbs +5 -5
- package/templates/docs/layout.hbs +221 -62
- package/templates/docs/pages/audit.hbs +83 -35
- package/templates/docs/pages/cli.hbs +18 -0
- package/templates/docs/pages/configuration.hbs +241 -0
- package/templates/docs/pages/datadog.hbs +46 -0
- package/templates/docs/pages/elasticsearch.hbs +121 -0
- package/templates/docs/pages/federation.hbs +241 -0
- package/templates/docs/pages/gdl-advanced.hbs +91 -0
- package/templates/docs/pages/gdl-annotations.hbs +137 -0
- package/templates/docs/pages/gdl-entities.hbs +134 -0
- package/templates/docs/pages/gdl-relationships.hbs +80 -0
- package/templates/docs/pages/gdl.hbs +60 -204
- package/templates/docs/pages/graphql.hbs +58 -44
- package/templates/docs/pages/grpc.hbs +53 -90
- package/templates/docs/pages/hybrid-store.hbs +127 -0
- package/templates/docs/pages/index.hbs +418 -149
- package/templates/docs/pages/keycloak.hbs +43 -0
- package/templates/docs/pages/legend.hbs +116 -0
- package/templates/docs/pages/mosquitto.hbs +39 -0
- package/templates/docs/pages/multitenancy.hbs +139 -71
- package/templates/docs/pages/otel.hbs +40 -0
- package/templates/docs/pages/realtime.hbs +38 -12
- package/templates/docs/pages/redis.hbs +40 -0
- package/templates/docs/pages/rest.hbs +120 -202
- package/templates/docs/pages/saga.hbs +94 -0
- package/templates/docs/pages/security.hbs +150 -44
- package/templates/docs/pages/serverless.hbs +157 -0
- package/templates/docs/pages/storage.hbs +127 -0
- package/templates/docs/pages/wizard.hbs +683 -0
- package/templates/docs/triple_identity_registry.png +0 -0
- package/templates/go/controller.go.hbs +287 -283
- package/templates/go/entity.go.hbs +17 -15
- package/templates/go/main.go.hbs +47 -180
- package/templates/go/migrator.go.hbs +65 -0
- package/templates/go/router.go.hbs +272 -0
- package/templates/graphql/resolver.go.hbs +53 -34
- package/templates/graphql/schema.graphql.hbs +17 -5
- package/templates/kratos/service.go.hbs +169 -34
- package/templates/proto/entity.proto.hbs +10 -14
- package/test_nested.gdl +21 -0
- package/templates/docs/intro.mp4 +0 -0
- package/test_parser.js +0 -9
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
+
import Handlebars from 'handlebars';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Handlebars.registerHelper('capitalize', (str) => {
|
|
7
|
+
if (!str) return '';
|
|
8
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const generateMultitenancy = async (config, outputDir, entities = []) => {
|
|
6
12
|
|
|
7
13
|
const multitenancyTemplate = `
|
|
8
14
|
package middleware
|
|
9
15
|
|
|
10
16
|
import (
|
|
17
|
+
"context"
|
|
11
18
|
"fmt"
|
|
12
19
|
"net/http"
|
|
20
|
+
"strings"
|
|
13
21
|
"sync"
|
|
22
|
+
"sort"
|
|
23
|
+
"time"
|
|
14
24
|
|
|
15
25
|
"github.com/gin-gonic/gin"
|
|
26
|
+
"go.mongodb.org/mongo-driver/mongo"
|
|
27
|
+
"go.mongodb.org/mongo-driver/mongo/options"
|
|
16
28
|
"gorm.io/driver/postgres"
|
|
17
29
|
"gorm.io/gorm"
|
|
18
30
|
"{{app_name}}/config"
|
|
31
|
+
"{{app_name}}/models"
|
|
19
32
|
)
|
|
20
33
|
|
|
21
34
|
// TenantDBManager handles dynamic connection pooling for all tenants
|
|
22
35
|
type TenantDBManager struct {
|
|
23
|
-
masterDB
|
|
24
|
-
configs
|
|
25
|
-
conns
|
|
26
|
-
|
|
36
|
+
masterDB *gorm.DB
|
|
37
|
+
configs *config.Config
|
|
38
|
+
conns map[string]*gorm.DB
|
|
39
|
+
mongoConns map[string]*mongo.Client
|
|
40
|
+
mu sync.RWMutex
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
var (
|
|
@@ -34,9 +48,10 @@ var (
|
|
|
34
48
|
func GetTenantManager(db *gorm.DB, cfg *config.Config) *TenantDBManager {
|
|
35
49
|
once.Do(func() {
|
|
36
50
|
manager = &TenantDBManager{
|
|
37
|
-
masterDB:
|
|
38
|
-
configs:
|
|
39
|
-
conns:
|
|
51
|
+
masterDB: db,
|
|
52
|
+
configs: cfg,
|
|
53
|
+
conns: make(map[string]*gorm.DB),
|
|
54
|
+
mongoConns: make(map[string]*mongo.Client),
|
|
40
55
|
}
|
|
41
56
|
})
|
|
42
57
|
return manager
|
|
@@ -68,18 +83,55 @@ func (m *TenantDBManager) GetDB(dbName string) (*gorm.DB, error) {
|
|
|
68
83
|
return nil, err
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
sqlDB, err := newDB.DB()
|
|
87
|
+
if err == nil {
|
|
88
|
+
sqlDB.SetMaxOpenConns(5)
|
|
89
|
+
sqlDB.SetMaxIdleConns(2)
|
|
90
|
+
sqlDB.SetConnMaxLifetime(m.configs.GoDuck.Datasource.ConnMaxLifetime)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Ensure Silo supports Distributed Outbox (Saga Consistency)
|
|
94
|
+
newDB.AutoMigrate(&models.DistributedOutbox{})
|
|
95
|
+
|
|
71
96
|
m.conns[dbName] = newDB
|
|
72
97
|
return newDB, nil
|
|
73
98
|
}
|
|
74
99
|
|
|
100
|
+
func (m *TenantDBManager) GetMongoClient(dbName string) (*mongo.Client, error) {
|
|
101
|
+
m.mu.RLock()
|
|
102
|
+
if client, ok := m.mongoConns[dbName]; ok {
|
|
103
|
+
m.mu.RUnlock()
|
|
104
|
+
return client, nil
|
|
105
|
+
}
|
|
106
|
+
m.mu.RUnlock()
|
|
107
|
+
|
|
108
|
+
m.mu.Lock()
|
|
109
|
+
defer m.mu.Unlock()
|
|
110
|
+
|
|
111
|
+
if client, ok := m.mongoConns[dbName]; ok {
|
|
112
|
+
return client, nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
116
|
+
defer cancel()
|
|
117
|
+
|
|
118
|
+
// For multi-tenancy in MongoDB, we either use one client and switch DBs,
|
|
119
|
+
// or create a client per DB if they are on different hosts.
|
|
120
|
+
// Here we assume same host, different DBs for performance.
|
|
121
|
+
client, err := mongo.Connect(ctx, options.Client().ApplyURI(m.configs.GetMongoURI()))
|
|
122
|
+
if err != nil {
|
|
123
|
+
return nil, err
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
m.mongoConns[dbName] = client
|
|
127
|
+
return client, nil
|
|
128
|
+
}
|
|
129
|
+
|
|
75
130
|
func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
76
131
|
mgr := GetTenantManager(db, cfg)
|
|
132
|
+
fallbackDB := strings.ToLower(cfg.GoDuck.Name) + "_fallback"
|
|
77
133
|
|
|
78
134
|
return func(c *gin.Context) {
|
|
79
|
-
// 1. Identification (Hint from Header)
|
|
80
|
-
requestedTenant := c.GetHeader("X-Tenant-ID")
|
|
81
|
-
|
|
82
|
-
// 2. Authorization (Extracted from JWT by JWTMiddleware)
|
|
83
135
|
userRolesInterface, exists := c.Get("UserRoles")
|
|
84
136
|
if !exists {
|
|
85
137
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "No roles found in token"})
|
|
@@ -94,24 +146,110 @@ func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
|
94
146
|
return
|
|
95
147
|
}
|
|
96
148
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
149
|
+
var lowerRoles []string
|
|
150
|
+
isAdmin := false
|
|
151
|
+
for _, r := range roles {
|
|
152
|
+
roleStr := strings.ToLower(fmt.Sprintf("%v", r))
|
|
153
|
+
lowerRoles = append(lowerRoles, roleStr)
|
|
154
|
+
if roleStr == "admin" || roleStr == "role_admin" {
|
|
155
|
+
isAdmin = true
|
|
156
|
+
}
|
|
105
157
|
}
|
|
106
158
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
159
|
+
siloConnections := make(map[string]*gorm.DB)
|
|
160
|
+
mongoConnections := make(map[string]*mongo.Database)
|
|
161
|
+
var mappings []models.TenantRole
|
|
162
|
+
|
|
163
|
+
requestedTenantRaw := strings.ToLower(c.GetHeader("X-Tenant-ID"))
|
|
164
|
+
var requestedTenants []string
|
|
165
|
+
if requestedTenantRaw != "" {
|
|
166
|
+
parts := strings.Split(requestedTenantRaw, ",")
|
|
167
|
+
for _, p := range parts {
|
|
168
|
+
trimmed := strings.TrimSpace(p)
|
|
169
|
+
if trimmed != "" {
|
|
170
|
+
requestedTenants = append(requestedTenants, trimmed)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if len(requestedTenants) > 0 {
|
|
176
|
+
db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE role_name IN ? AND tenant_id IN ?", lowerRoles, requestedTenants).Scan(&mappings)
|
|
177
|
+
} else {
|
|
178
|
+
db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE role_name IN ?", lowerRoles).Scan(&mappings)
|
|
112
179
|
}
|
|
113
180
|
|
|
114
|
-
|
|
181
|
+
if len(mappings) == 0 {
|
|
182
|
+
conn, _ := mgr.GetDB(fallbackDB)
|
|
183
|
+
siloConnections["fallback"] = conn
|
|
184
|
+
c.Set("tenantDBConn", conn)
|
|
185
|
+
|
|
186
|
+
if cfg.GoDuck.Datasource.MongoDB.Enabled {
|
|
187
|
+
mClient, _ := mgr.GetMongoClient(fallbackDB)
|
|
188
|
+
mDB := mClient.Database(fallbackDB)
|
|
189
|
+
mongoConnections["fallback"] = mDB
|
|
190
|
+
c.Set("tenantMongoDB", mDB)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
c.Set("primaryRole", "fallback")
|
|
194
|
+
} else {
|
|
195
|
+
// Sort by is_primary to ensure primary silo is selected as the default connection
|
|
196
|
+
sort.Slice(mappings, func(i, j int) bool {
|
|
197
|
+
return mappings[i].IsPrimary && !mappings[j].IsPrimary
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
for _, m := range mappings {
|
|
201
|
+
if m.DBName == "admin_db" && !isAdmin { continue }
|
|
202
|
+
conn, err := mgr.GetDB(m.DBName)
|
|
203
|
+
if err == nil {
|
|
204
|
+
siloConnections[m.RoleName] = conn
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if cfg.GoDuck.Datasource.MongoDB.Enabled {
|
|
208
|
+
mClient, err := mgr.GetMongoClient(m.DBName)
|
|
209
|
+
if err == nil {
|
|
210
|
+
mongoConnections[m.RoleName] = mClient.Database(m.DBName)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The FIRST one in our sorted list is the primary/lead silo for this request
|
|
216
|
+
if len(mappings) > 0 {
|
|
217
|
+
primaryConn, _ := mgr.GetDB(mappings[0].DBName)
|
|
218
|
+
c.Set("tenantDBConn", primaryConn)
|
|
219
|
+
|
|
220
|
+
if cfg.GoDuck.Datasource.MongoDB.Enabled {
|
|
221
|
+
mClient, _ := mgr.GetMongoClient(mappings[0].DBName)
|
|
222
|
+
c.Set("tenantMongoDB", mClient.Database(mappings[0].DBName))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
c.Set("primaryRole", mappings[0].RoleName)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
c.Set("tenantSiloConns", siloConnections)
|
|
230
|
+
c.Set("tenantMongoConnections", mongoConnections)
|
|
231
|
+
c.Set("isAdmin", isAdmin)
|
|
232
|
+
c.Next()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func PublicTenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
237
|
+
mgr := GetTenantManager(db, cfg)
|
|
238
|
+
fallbackDB := strings.ToLower(cfg.GoDuck.Name) + "_fallback"
|
|
239
|
+
|
|
240
|
+
return func(c *gin.Context) {
|
|
241
|
+
requestedTenant := strings.ToLower(c.GetHeader("X-Tenant-ID"))
|
|
242
|
+
|
|
243
|
+
var mappings []models.TenantRole
|
|
244
|
+
if requestedTenant != "" {
|
|
245
|
+
db.Raw("SELECT db_name, is_primary FROM tenant_roles WHERE tenant_id = ? ORDER BY is_primary DESC LIMIT 1", requestedTenant).Scan(&mappings)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
dbName := fallbackDB
|
|
249
|
+
if len(mappings) > 0 {
|
|
250
|
+
dbName = mappings[0].DBName
|
|
251
|
+
}
|
|
252
|
+
|
|
115
253
|
tenantConn, err := mgr.GetDB(dbName)
|
|
116
254
|
if err != nil {
|
|
117
255
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve tenant database connection"})
|
|
@@ -119,9 +257,15 @@ func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
|
119
257
|
return
|
|
120
258
|
}
|
|
121
259
|
|
|
122
|
-
// 6. Inject Live Connection into Context
|
|
123
|
-
c.Set("tenantDB", dbName)
|
|
124
260
|
c.Set("tenantDBConn", tenantConn)
|
|
261
|
+
|
|
262
|
+
if cfg.GoDuck.Datasource.MongoDB.Enabled {
|
|
263
|
+
mClient, err := mgr.GetMongoClient(dbName)
|
|
264
|
+
if err == nil {
|
|
265
|
+
c.Set("tenantMongoDB", mClient.Database(dbName))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
125
269
|
c.Next()
|
|
126
270
|
}
|
|
127
271
|
}
|
|
@@ -133,77 +277,136 @@ package management
|
|
|
133
277
|
import (
|
|
134
278
|
"fmt"
|
|
135
279
|
"net/http"
|
|
136
|
-
"
|
|
280
|
+
"strings"
|
|
137
281
|
"{{app_name}}/config"
|
|
282
|
+
"{{app_name}}/middleware"
|
|
283
|
+
"{{app_name}}/migrations"
|
|
284
|
+
"{{app_name}}/models"
|
|
138
285
|
|
|
139
286
|
"github.com/gin-gonic/gin"
|
|
287
|
+
"github.com/google/uuid"
|
|
140
288
|
"gorm.io/gorm"
|
|
141
289
|
)
|
|
142
290
|
|
|
143
291
|
type DatabaseRequest struct {
|
|
144
|
-
|
|
145
|
-
DBName
|
|
292
|
+
RoleName string \`json:"roleName" form:"roleName" binding:"required"\`
|
|
293
|
+
DBName string \`json:"dbName" form:"dbName" binding:"required"\`
|
|
294
|
+
IsPrimary bool \`json:"isPrimary" form:"isPrimary"\`
|
|
146
295
|
}
|
|
147
296
|
|
|
148
297
|
func CreateDatabaseAndMigrate(masterDB *gorm.DB) gin.HandlerFunc {
|
|
149
298
|
return func(c *gin.Context) {
|
|
150
299
|
var req DatabaseRequest
|
|
151
300
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
152
|
-
|
|
153
|
-
|
|
301
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
302
|
+
return
|
|
154
303
|
}
|
|
155
304
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
305
|
+
req.DBName = strings.ToLower(req.DBName)
|
|
306
|
+
req.RoleName = strings.ToLower(req.RoleName)
|
|
307
|
+
|
|
308
|
+
// 1. Ensure DB exists
|
|
309
|
+
var exists int
|
|
310
|
+
masterDB.Raw("SELECT 1 FROM pg_database WHERE datname = ?", req.DBName).Scan(&exists)
|
|
311
|
+
if exists == 0 {
|
|
312
|
+
masterDB.Exec(fmt.Sprintf("CREATE DATABASE %s", req.DBName))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 2. Clear other primary flags for this role if setting new primary
|
|
316
|
+
if req.IsPrimary {
|
|
317
|
+
masterDB.Model(&models.TenantRole{}).Where("role_name = ?", req.RoleName).Update("is_primary", false)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 3. Upsert mapping
|
|
321
|
+
var existing models.TenantRole
|
|
322
|
+
if err := masterDB.Where("role_name = ? AND db_name = ?", req.RoleName, req.DBName).First(&existing).Error; err == nil {
|
|
323
|
+
existing.IsPrimary = req.IsPrimary
|
|
324
|
+
masterDB.Save(&existing)
|
|
325
|
+
} else {
|
|
326
|
+
masterDB.Create(&models.TenantRole{
|
|
327
|
+
TenantID: uuid.New().String(),
|
|
328
|
+
RoleName: req.RoleName,
|
|
329
|
+
DBName: req.DBName,
|
|
330
|
+
IsPrimary: req.IsPrimary,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 4. Migrate
|
|
335
|
+
appConfig, _ := config.LoadConfig()
|
|
336
|
+
mgr := middleware.GetTenantManager(masterDB, appConfig)
|
|
337
|
+
|
|
338
|
+
tenantDB, err := mgr.GetDB(req.DBName)
|
|
339
|
+
if err == nil {
|
|
340
|
+
migrations.RunGoNativeMigrationsForTenant(tenantDB)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
c.JSON(http.StatusOK, gin.H{"message": "Role silo assigned successfully", "role": req.RoleName, "primary": req.IsPrimary})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func GetMySilos(masterDB *gorm.DB) gin.HandlerFunc {
|
|
348
|
+
return func(c *gin.Context) {
|
|
349
|
+
userRoles, _ := c.Get("UserRoles")
|
|
350
|
+
roles, ok := userRoles.([]interface{})
|
|
351
|
+
if !ok {
|
|
352
|
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
161
353
|
return
|
|
162
354
|
}
|
|
163
355
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
356
|
+
var lowerRoles []string
|
|
357
|
+
for _, r := range roles {
|
|
358
|
+
lowerRoles = append(lowerRoles, strings.ToLower(fmt.Sprintf("%v", r)))
|
|
168
359
|
}
|
|
169
360
|
|
|
170
|
-
|
|
361
|
+
var mappings []models.TenantRole
|
|
362
|
+
masterDB.Where("role_name IN ?", lowerRoles).Find(&mappings)
|
|
363
|
+
|
|
171
364
|
appConfig, _ := config.LoadConfig()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
365
|
+
hideDBNames := appConfig.GoDuck.Multitenancy.HideSiloNames
|
|
366
|
+
|
|
367
|
+
type SiloInfo struct {
|
|
368
|
+
TenantID string \`json:"tenantId"\`
|
|
369
|
+
RoleName string \`json:"roleName"\`
|
|
370
|
+
DBName string \`json:"dbName,omitempty"\`
|
|
371
|
+
IsPrimary bool \`json:"isPrimary"\`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
var result []SiloInfo
|
|
375
|
+
for _, m := range mappings {
|
|
376
|
+
info := SiloInfo{
|
|
377
|
+
TenantID: m.TenantID,
|
|
378
|
+
RoleName: m.RoleName,
|
|
379
|
+
IsPrimary: m.IsPrimary,
|
|
380
|
+
}
|
|
381
|
+
if !hideDBNames {
|
|
382
|
+
info.DBName = m.DBName
|
|
383
|
+
}
|
|
384
|
+
result = append(result, info)
|
|
192
385
|
}
|
|
193
386
|
|
|
194
|
-
c.JSON(http.StatusOK,
|
|
387
|
+
c.JSON(http.StatusOK, result)
|
|
195
388
|
}
|
|
196
389
|
}
|
|
197
390
|
`;
|
|
198
391
|
|
|
199
|
-
const
|
|
200
|
-
|
|
392
|
+
const tenantRoleModel = `
|
|
393
|
+
package models
|
|
201
394
|
|
|
202
|
-
|
|
203
|
-
|
|
395
|
+
type TenantRole struct {
|
|
396
|
+
ID uint \`gorm:"primaryKey" json:"id"\`
|
|
397
|
+
TenantID string \`json:"tenantId" gorm:"unique;not null;index"\`
|
|
398
|
+
RoleName string \`json:"roleName" gorm:"unique;not null"\`
|
|
399
|
+
DBName string \`json:"dbName" gorm:"not null"\`
|
|
400
|
+
MongoDBName string \`json:"mongoDbName"\`
|
|
401
|
+
IsPrimary bool \`json:"isPrimary" gorm:"default:false"\`
|
|
402
|
+
}
|
|
403
|
+
`;
|
|
204
404
|
|
|
205
|
-
|
|
206
|
-
|
|
405
|
+
await fs.ensureDir(path.join(outputDir, 'middleware'));
|
|
406
|
+
await fs.ensureDir(path.join(outputDir, 'management'));
|
|
407
|
+
await fs.ensureDir(path.join(outputDir, 'models'));
|
|
207
408
|
|
|
208
|
-
|
|
409
|
+
await fs.writeFile(path.join(outputDir, 'middleware/tenant_middleware.go'), Handlebars.compile(multitenancyTemplate)({ app_name: config.name, entities }));
|
|
410
|
+
await fs.writeFile(path.join(outputDir, 'management/db_controller.go'), Handlebars.compile(dbApiTemplate)({ app_name: config.name, entities }));
|
|
411
|
+
await fs.writeFile(path.join(outputDir, 'models/tenant_role.go'), tenantRoleModel);
|
|
209
412
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export const generateNATSCode = async (config, outputDir) => {
|
|
6
|
+
const messagingDir = path.join(outputDir, 'messaging');
|
|
7
|
+
await fs.ensureDir(messagingDir);
|
|
8
|
+
|
|
9
|
+
const natsClientGo = `package messaging
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"log"
|
|
13
|
+
|
|
14
|
+
"{{app_name}}/config"
|
|
15
|
+
"github.com/nats-io/nats.go"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
var NATSConn *nats.Conn
|
|
19
|
+
|
|
20
|
+
func InitNATS(cfg *config.Config) {
|
|
21
|
+
if !cfg.GoDuck.Messaging.NATS.Enabled {
|
|
22
|
+
log.Println("NATS Messaging is disabled.")
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
nc, err := nats.Connect(cfg.GoDuck.Messaging.NATS.URL)
|
|
27
|
+
if err != nil {
|
|
28
|
+
log.Printf("Failed to connect to NATS: %v", err)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
log.Printf("Connected to NATS Server: %s", cfg.GoDuck.Messaging.NATS.URL)
|
|
33
|
+
NATSConn = nc
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
await fs.writeFile(path.join(messagingDir, 'nats.go'), natsClientGo.replace(/{{app_name}}/g, config.name));
|
|
38
|
+
console.log(chalk.gray(' Generated NATS Messaging Package'));
|
|
39
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export const generateOutbox = async (outputDir, config) => {
|
|
6
|
+
const modelsDir = path.join(outputDir, 'models');
|
|
7
|
+
const workerDir = path.join(outputDir, 'internal/worker');
|
|
8
|
+
await fs.ensureDir(modelsDir);
|
|
9
|
+
await fs.ensureDir(workerDir);
|
|
10
|
+
|
|
11
|
+
const outboxModel = `package models
|
|
12
|
+
|
|
13
|
+
import (
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type DistributedOutbox struct {
|
|
18
|
+
ID uint \`gorm:"primaryKey" json:"id"\`
|
|
19
|
+
MutationType string \`json:"mutationType"\` // CREATE, UPDATE, PATCH, DELETE, BULK_CREATE, etc.
|
|
20
|
+
EntityName string \`json:"entityName"\`
|
|
21
|
+
EntityID string \`json:"entityId"\`
|
|
22
|
+
Payload string \`json:"payload" gorm:"type:text"\`
|
|
23
|
+
TargetSilo string \`json:"targetSilo"\` // Which role/silo to sync to
|
|
24
|
+
SourceSilo string \`json:"sourceSilo"\` // Which role/silo originated this
|
|
25
|
+
Status string \`json:"status" gorm:"default:'PENDING'"\` // PENDING, COMPLETED, FAILED
|
|
26
|
+
RetryCount int \`json:"retryCount" gorm:"default:0"\`
|
|
27
|
+
LastError string \`json:"lastError"\`
|
|
28
|
+
CreatedAt time.Time \`json:"createdAt"\`
|
|
29
|
+
UpdatedAt time.Time \`json:"updatedAt"\`
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const outboxWorker = `package worker
|
|
34
|
+
|
|
35
|
+
import (
|
|
36
|
+
"context"
|
|
37
|
+
"encoding/json"
|
|
38
|
+
"fmt"
|
|
39
|
+
"strings"
|
|
40
|
+
"time"
|
|
41
|
+
"{{app_name}}/logger"
|
|
42
|
+
"{{app_name}}/models"
|
|
43
|
+
"{{app_name}}/config"
|
|
44
|
+
"{{app_name}}/middleware"
|
|
45
|
+
"gorm.io/gorm"
|
|
46
|
+
"gorm.io/gorm/clause"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
type OutboxWorker struct {
|
|
50
|
+
MasterDB *gorm.DB
|
|
51
|
+
Config *config.Config
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func NewOutboxWorker(db *gorm.DB, cfg *config.Config) *OutboxWorker {
|
|
55
|
+
return &OutboxWorker{MasterDB: db, Config: cfg}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (w *OutboxWorker) Start(ctx context.Context) {
|
|
59
|
+
ticker := time.NewTicker(10 * time.Second) // Poll every 10 seconds
|
|
60
|
+
logger.Info("Distributed Outbox (Saga Consistency) Worker started")
|
|
61
|
+
|
|
62
|
+
for {
|
|
63
|
+
select {
|
|
64
|
+
case <-ctx.Done():
|
|
65
|
+
return
|
|
66
|
+
case <-ticker.C:
|
|
67
|
+
w.processOutbox()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func (w *OutboxWorker) processOutbox() {
|
|
73
|
+
// 1. Identify all managed silos
|
|
74
|
+
var siloMappings []models.TenantRole
|
|
75
|
+
if err := w.MasterDB.Find(&siloMappings).Error; err != nil {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
mgr := middleware.GetTenantManager(w.MasterDB, w.Config)
|
|
80
|
+
|
|
81
|
+
// 2. Poll each silo for pending outbox items originated there
|
|
82
|
+
for _, mapping := range siloMappings {
|
|
83
|
+
srcDB, err := mgr.GetDB(mapping.DBName)
|
|
84
|
+
if err != nil {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
var pending []models.DistributedOutbox
|
|
89
|
+
if err := srcDB.Limit(50).Where("status = ? AND retry_count < 5", "PENDING").Find(&pending).Error; err != nil {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for _, item := range pending {
|
|
94
|
+
// Resolve Target DB Name
|
|
95
|
+
var targetMapping models.TenantRole
|
|
96
|
+
if err := w.MasterDB.Where("role_name = ?", item.TargetSilo).First(&targetMapping).Error; err != nil {
|
|
97
|
+
w.markFailed(srcDB, &item, "Silo resolution failed: "+err.Error())
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
targetDB, err := mgr.GetDB(targetMapping.DBName)
|
|
102
|
+
if err != nil {
|
|
103
|
+
w.markFailed(srcDB, &item, "Target DB connect failed: "+err.Error())
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if err := w.relayMutation(targetDB, item); err != nil {
|
|
108
|
+
w.markFailed(srcDB, &item, "Relay failed: "+err.Error())
|
|
109
|
+
} else {
|
|
110
|
+
srcDB.Model(&item).Update("status", "COMPLETED")
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (w *OutboxWorker) markFailed(db *gorm.DB, item *models.DistributedOutbox, reason string) {
|
|
117
|
+
db.Model(item).Updates(map[string]interface{}{
|
|
118
|
+
"status": "PENDING", // Keep pending so we can retry
|
|
119
|
+
"retry_count": item.RetryCount + 1,
|
|
120
|
+
"last_error": reason,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func (w *OutboxWorker) relayMutation(targetDB *gorm.DB, item models.DistributedOutbox) error {
|
|
125
|
+
var data map[string]interface{}
|
|
126
|
+
if item.Payload != "" && item.MutationType != "DELETE" {
|
|
127
|
+
if err := json.Unmarshal([]byte(item.Payload), &data); err != nil {
|
|
128
|
+
return fmt.Errorf("payload unmarshal failed: %v", err)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
table := strings.ToLower(item.EntityName)
|
|
133
|
+
|
|
134
|
+
switch item.MutationType {
|
|
135
|
+
case "CREATE":
|
|
136
|
+
return targetDB.Table(table).Clauses(clause.OnConflict{DoNothing: true}).Create(data).Error
|
|
137
|
+
case "UPDATE", "PATCH":
|
|
138
|
+
return targetDB.Table(table).Where("id = ?", item.EntityID).Updates(data).Error
|
|
139
|
+
case "DELETE":
|
|
140
|
+
return targetDB.Table(table).Where("id = ?", item.EntityID).Delete(nil).Error
|
|
141
|
+
case "BULK_CREATE":
|
|
142
|
+
var list []map[string]interface{}
|
|
143
|
+
json.Unmarshal([]byte(item.Payload), &list)
|
|
144
|
+
return targetDB.Table(table).Clauses(clause.OnConflict{DoNothing: true}).Create(list).Error
|
|
145
|
+
case "BULK_UPDATE", "BULK_PATCH":
|
|
146
|
+
var list []map[string]interface{}
|
|
147
|
+
json.Unmarshal([]byte(item.Payload), &list)
|
|
148
|
+
err := targetDB.Transaction(func(tx *gorm.DB) error {
|
|
149
|
+
for _, row := range list {
|
|
150
|
+
// Identify ID for updates in bulk
|
|
151
|
+
id := row["id"]
|
|
152
|
+
if id == nil {
|
|
153
|
+
id = row["ID"]
|
|
154
|
+
}
|
|
155
|
+
if err := tx.Table(table).Where("id = ?", id).Updates(row).Error; err != nil {
|
|
156
|
+
return err
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return nil
|
|
160
|
+
})
|
|
161
|
+
return err
|
|
162
|
+
}
|
|
163
|
+
return fmt.Errorf("unknown mutation type: %s", item.MutationType)
|
|
164
|
+
}
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
await fs.writeFile(path.join(modelsDir, 'outbox.go'), outboxModel);
|
|
168
|
+
await fs.writeFile(path.join(workerDir, 'outbox_worker.go'), outboxWorker.replace(/{{app_name}}/g, config.name));
|
|
169
|
+
|
|
170
|
+
console.log(chalk.gray(' Generated Distributed Outbox Model & Worker Skeleton'));
|
|
171
|
+
};
|
package/generators/postgrest.js
CHANGED
|
@@ -26,7 +26,11 @@ type SearchController struct {
|
|
|
26
26
|
// Syntax: /api/search/:table?age=gt.20&order=id.desc&limit=10&offset=0
|
|
27
27
|
func (sc *SearchController) GenericSearch(c *gin.Context) {
|
|
28
28
|
tableName := c.Param("table")
|
|
29
|
-
|
|
29
|
+
db := sc.DB
|
|
30
|
+
if tdb, exists := c.Get("tenantDBConn"); exists {
|
|
31
|
+
db = tdb.(*gorm.DB)
|
|
32
|
+
}
|
|
33
|
+
query := db.Table(tableName)
|
|
30
34
|
|
|
31
35
|
// Apply Filters
|
|
32
36
|
params := c.Request.URL.Query()
|
|
@@ -48,10 +52,10 @@ func (sc *SearchController) GenericSearch(c *gin.Context) {
|
|
|
48
52
|
path = path[1:]
|
|
49
53
|
}
|
|
50
54
|
// Wrap column in quotes and path in single quotes for Postgres JSONB safety
|
|
51
|
-
processedKey = fmt.Sprintf("
|
|
55
|
+
processedKey = fmt.Sprintf("\\"%s\\"%s'%s'", column, operator, path)
|
|
52
56
|
} else {
|
|
53
57
|
// Standard column: Wrap in quotes for safety
|
|
54
|
-
processedKey = fmt.Sprintf("
|
|
58
|
+
processedKey = fmt.Sprintf("\\"%s\\"", key)
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
for _, val := range values {
|