go-duck-cli 1.0.8 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +30 -15
  2. package/generators/ai_docs.js +130 -0
  3. package/generators/broker.js +63 -0
  4. package/generators/config.js +149 -7
  5. package/generators/devops.js +210 -43
  6. package/generators/docs.js +23 -4
  7. package/generators/elasticsearch.js +263 -0
  8. package/generators/kratos.js +229 -41
  9. package/generators/metering.js +280 -48
  10. package/generators/migrations.js +92 -198
  11. package/generators/mqtt.js +2 -39
  12. package/generators/multitenancy.js +274 -71
  13. package/generators/nats.js +39 -0
  14. package/generators/outbox.js +171 -0
  15. package/generators/postgrest.js +7 -3
  16. package/generators/postman.js +405 -0
  17. package/generators/repository.js +27 -0
  18. package/generators/router.js +27 -0
  19. package/generators/security.js +95 -14
  20. package/generators/serverless.js +147 -0
  21. package/generators/storage.js +589 -0
  22. package/generators/swagger.js +84 -60
  23. package/generators/telemetry.js +23 -32
  24. package/generators/websocket.js +55 -21
  25. package/index.js +481 -116
  26. package/package.json +6 -4
  27. package/parser/gdl.js +163 -24
  28. package/templates/docs/index.html.hbs +5 -5
  29. package/templates/docs/layout.hbs +221 -62
  30. package/templates/docs/pages/audit.hbs +83 -35
  31. package/templates/docs/pages/cli.hbs +18 -0
  32. package/templates/docs/pages/configuration.hbs +241 -0
  33. package/templates/docs/pages/datadog.hbs +46 -0
  34. package/templates/docs/pages/elasticsearch.hbs +121 -0
  35. package/templates/docs/pages/federation.hbs +241 -0
  36. package/templates/docs/pages/gdl-advanced.hbs +91 -0
  37. package/templates/docs/pages/gdl-annotations.hbs +137 -0
  38. package/templates/docs/pages/gdl-entities.hbs +134 -0
  39. package/templates/docs/pages/gdl-relationships.hbs +80 -0
  40. package/templates/docs/pages/gdl.hbs +60 -204
  41. package/templates/docs/pages/graphql.hbs +58 -44
  42. package/templates/docs/pages/grpc.hbs +53 -90
  43. package/templates/docs/pages/hybrid-store.hbs +127 -0
  44. package/templates/docs/pages/index.hbs +418 -149
  45. package/templates/docs/pages/keycloak.hbs +43 -0
  46. package/templates/docs/pages/legend.hbs +116 -0
  47. package/templates/docs/pages/mosquitto.hbs +39 -0
  48. package/templates/docs/pages/multitenancy.hbs +139 -71
  49. package/templates/docs/pages/otel.hbs +40 -0
  50. package/templates/docs/pages/realtime.hbs +38 -12
  51. package/templates/docs/pages/redis.hbs +40 -0
  52. package/templates/docs/pages/rest.hbs +120 -202
  53. package/templates/docs/pages/saga.hbs +94 -0
  54. package/templates/docs/pages/security.hbs +150 -44
  55. package/templates/docs/pages/serverless.hbs +157 -0
  56. package/templates/docs/pages/storage.hbs +127 -0
  57. package/templates/docs/pages/wizard.hbs +683 -0
  58. package/templates/docs/triple_identity_registry.png +0 -0
  59. package/templates/go/controller.go.hbs +287 -283
  60. package/templates/go/entity.go.hbs +17 -15
  61. package/templates/go/main.go.hbs +47 -180
  62. package/templates/go/migrator.go.hbs +65 -0
  63. package/templates/go/router.go.hbs +272 -0
  64. package/templates/graphql/resolver.go.hbs +53 -34
  65. package/templates/graphql/schema.graphql.hbs +17 -5
  66. package/templates/kratos/service.go.hbs +169 -34
  67. package/templates/proto/entity.proto.hbs +10 -14
  68. package/test_nested.gdl +21 -0
  69. package/templates/docs/intro.mp4 +0 -0
  70. 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
- export const generateMultitenancy = async (config, outputDir) => {
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 *gorm.DB
24
- configs *config.Config
25
- conns map[string]*gorm.DB
26
- mu sync.RWMutex
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: db,
38
- configs: cfg,
39
- conns: make(map[string]*gorm.DB),
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
- // 3. Resolution (Which DB is this role authorized to access?)
98
- var dbName string
99
- err := db.Raw("SELECT db_name FROM tenant_roles WHERE role_name IN ? LIMIT 1", roles).Scan(&dbName).Error
100
-
101
- if err != nil || dbName == "" {
102
- c.JSON(http.StatusForbidden, gin.H{"error": "Security: No tenant context mapped to user roles"})
103
- c.Abort()
104
- return
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
- // 4. Security Check (Prevent Cross-Tenant Spoofing)
108
- if requestedTenant != "" && requestedTenant != dbName {
109
- c.JSON(http.StatusForbidden, gin.H{"error": "Security Breach: Header/Token tenant mismatch"})
110
- c.Abort()
111
- return
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
- // 5. Dynamic Switching (Get or Create the DB Connection)
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
- "os/exec"
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
- Role string \`json:"role" binding:"required"\`
145
- DBName string \`json:"db_name" binding:"required"\`
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
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
153
- return
301
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
302
+ return
154
303
  }
155
304
 
156
- // 1. CREATE DATABASE
157
- // Note: CREATE DATABASE cannot be run in a transaction.
158
- // We use the masterDB connection.
159
- if err := masterDB.Exec(fmt.Sprintf("CREATE DATABASE %s", req.DBName)).Error; err != nil {
160
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database: " + err.Error()})
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
- // 2. Insert into roles mapping table
165
- if err := masterDB.Exec("INSERT INTO tenant_roles (role_name, db_name) VALUES (?, ?)", req.Role, req.DBName).Error; err != nil {
166
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to map role: " + err.Error()})
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
- // 3. Start Liquibase Migration for the new tenant
361
+ var mappings []models.TenantRole
362
+ masterDB.Where("role_name IN ?", lowerRoles).Find(&mappings)
363
+
171
364
  appConfig, _ := config.LoadConfig()
172
- ds := appConfig.GoDuck.Datasource
173
-
174
- // Construct JDBC URL for the new database
175
- jdbcUrl := fmt.Sprintf("jdbc:postgresql://%s:%d/%s", ds.Host, ds.Port, req.DBName)
176
-
177
- fmt.Printf("Migrating new tenant DB: %s\\n", req.DBName)
178
-
179
- cmd := exec.Command("liquibase",
180
- "--url=" + jdbcUrl,
181
- "--username=" + ds.Username,
182
- "--password=" + ds.Password,
183
- "--changeLogFile=migrations/master.xml",
184
- "update")
185
-
186
- if err := cmd.Run(); err != nil {
187
- fmt.Printf("Liquibase Error: %v\\n", err)
188
- // We don't fail the whole request because the DB is created,
189
- // but we warn the admin.
190
- c.JSON(http.StatusOK, gin.H{"message": "Database created but migration failed to auto-start. Please run manually.", "error": err.Error()})
191
- return
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, gin.H{"message": "Database created, role mapped, and migration completed for " + req.Role})
387
+ c.JSON(http.StatusOK, result)
195
388
  }
196
389
  }
197
390
  `;
198
391
 
199
- const middlewarePath = path.join(outputDir, 'middleware/tenant_middleware.go');
200
- const dbApiPath = path.join(outputDir, 'management/db_controller.go');
392
+ const tenantRoleModel = `
393
+ package models
201
394
 
202
- await fs.ensureDir(path.join(outputDir, 'middleware'));
203
- await fs.ensureDir(path.join(outputDir, 'management'));
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
- await fs.writeFile(middlewarePath, multitenancyTemplate);
206
- await fs.writeFile(dbApiPath, dbApiTemplate.replace('{{app_name}}', config.name));
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
- console.log(chalk.gray(' Generated Multitenancy Middleware & Management API'));
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
+ };
@@ -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
- query := sc.DB.Table(tableName)
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("\"%s\"%s'%s'", column, operator, path)
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("\"%s\"", key)
58
+ processedKey = fmt.Sprintf("\\"%s\\"", key)
55
59
  }
56
60
 
57
61
  for _, val := range values {