go-duck-cli 1.0.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.
Files changed (49) hide show
  1. package/README.md +130 -0
  2. package/generators/cache.js +107 -0
  3. package/generators/config.js +173 -0
  4. package/generators/devops.js +212 -0
  5. package/generators/docs.js +74 -0
  6. package/generators/graphql.js +38 -0
  7. package/generators/kratos.js +157 -0
  8. package/generators/logger.js +68 -0
  9. package/generators/metering.js +143 -0
  10. package/generators/migrations.js +240 -0
  11. package/generators/mqtt.js +87 -0
  12. package/generators/multitenancy.js +130 -0
  13. package/generators/postgrest.js +115 -0
  14. package/generators/repository.js +28 -0
  15. package/generators/resilience.js +69 -0
  16. package/generators/security.js +168 -0
  17. package/generators/swagger.js +145 -0
  18. package/generators/telemetry.js +121 -0
  19. package/generators/websocket.js +162 -0
  20. package/index.js +592 -0
  21. package/package.json +23 -0
  22. package/parser/gdl.js +162 -0
  23. package/templates/application.yml.hbs +18 -0
  24. package/templates/docs/gin_bottle.png +0 -0
  25. package/templates/docs/index.html.hbs +226 -0
  26. package/templates/docs/intro.mp4 +0 -0
  27. package/templates/docs/kratos_mark.png +0 -0
  28. package/templates/docs/layout.hbs +106 -0
  29. package/templates/docs/logo.png +0 -0
  30. package/templates/docs/pages/audit.hbs +39 -0
  31. package/templates/docs/pages/cli.hbs +83 -0
  32. package/templates/docs/pages/gdl.hbs +223 -0
  33. package/templates/docs/pages/graphql.hbs +51 -0
  34. package/templates/docs/pages/grpc.hbs +100 -0
  35. package/templates/docs/pages/index.hbs +181 -0
  36. package/templates/docs/pages/integrations.hbs +83 -0
  37. package/templates/docs/pages/observability.hbs +34 -0
  38. package/templates/docs/pages/realtime.hbs +43 -0
  39. package/templates/docs/pages/rest.hbs +149 -0
  40. package/templates/docs/pages/security.hbs +31 -0
  41. package/templates/go/controller.go.hbs +236 -0
  42. package/templates/go/entity.go.hbs +34 -0
  43. package/templates/go/enum.go.hbs +7 -0
  44. package/templates/go/main.go.hbs +186 -0
  45. package/templates/graphql/resolver.go.hbs +50 -0
  46. package/templates/graphql/schema.graphql.hbs +64 -0
  47. package/templates/kratos/service.go.hbs +104 -0
  48. package/templates/proto/entity.proto.hbs +95 -0
  49. package/test_parser.js +9 -0
@@ -0,0 +1,38 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import Handlebars from 'handlebars';
5
+
6
+ export const generateGraphQLCode = async (config, entities, relationships, outputDir, enums = []) => {
7
+ const graphDir = path.join(outputDir, 'graph');
8
+ await fs.ensureDir(graphDir);
9
+
10
+ const templatesDir = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../templates/graphql');
11
+
12
+ // 1. Generate schema.graphqls
13
+ const schemaTemplatePath = path.join(templatesDir, 'schema.graphql.hbs');
14
+ if (await fs.pathExists(schemaTemplatePath)) {
15
+ const schemaTemplateSource = await fs.readFile(schemaTemplatePath, 'utf8');
16
+ const schemaTemplate = Handlebars.compile(schemaTemplateSource);
17
+ const schemaContent = schemaTemplate({ entities, relationships, enums });
18
+ await fs.writeFile(path.join(graphDir, 'schema.graphqls'), schemaContent);
19
+ console.log(chalk.gray(' Generated GraphQL Schema: schema.graphqls'));
20
+ }
21
+
22
+ // 2. Generate resolver.go
23
+ const resolverTemplatePath = path.join(templatesDir, 'resolver.go.hbs');
24
+ if (await fs.pathExists(resolverTemplatePath)) {
25
+ const resolverTemplateSource = await fs.readFile(resolverTemplatePath, 'utf8');
26
+ const resolverTemplate = Handlebars.compile(resolverTemplateSource);
27
+ const resolverContent = resolverTemplate({
28
+ app_name: config.name,
29
+ entities,
30
+ relationships,
31
+ enums
32
+ });
33
+ await fs.writeFile(path.join(graphDir, 'resolver.go'), resolverContent);
34
+ console.log(chalk.gray(' Generated GraphQL Resolvers: resolver.go'));
35
+ }
36
+
37
+ console.log(chalk.green('✅ GraphQL code generated successfully!'));
38
+ };
@@ -0,0 +1,157 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import Handlebars from 'handlebars';
4
+ import chalk from 'chalk';
5
+
6
+ export const generateKratosCode = async (entities, projectRootDir, projectName, enums = []) => {
7
+ console.log(chalk.cyan('Generating Kratos gRPC Services...'));
8
+
9
+ const apiDir = path.join(projectRootDir, 'api', 'v1');
10
+ const serviceDir = path.join(projectRootDir, 'internal', 'service');
11
+ const serverDir = path.join(projectRootDir, 'internal', 'server');
12
+
13
+ await fs.ensureDir(apiDir);
14
+ await fs.ensureDir(serviceDir);
15
+ await fs.ensureDir(serverDir);
16
+
17
+ const protoTemplateSource = await fs.readFile(path.join(process.cwd(), 'templates', 'proto', 'entity.proto.hbs'), 'utf8');
18
+ const protoTemplate = Handlebars.compile(protoTemplateSource);
19
+
20
+ const serviceTemplateSource = await fs.readFile(path.join(process.cwd(), 'templates', 'kratos', 'service.go.hbs'), 'utf8');
21
+ const serviceTemplate = Handlebars.compile(serviceTemplateSource);
22
+
23
+ // Helpers for Proto types
24
+ Handlebars.registerHelper('toProtoType', (type) => {
25
+ const isEnum = enums.some(e => e.name === type);
26
+ // Map enums to string in proto to match the Go string enums for simpler POC
27
+ if (isEnum) return 'string';
28
+
29
+ const map = {
30
+ 'String': 'string',
31
+ 'Integer': 'int32',
32
+ 'Long': 'int64',
33
+ 'Float': 'float',
34
+ 'BigDecimal': 'double',
35
+ 'Boolean': 'bool',
36
+ 'LocalDate': 'string',
37
+ 'Instant': 'string',
38
+ 'Text': 'string',
39
+ 'JSON': 'string',
40
+ 'JSONB': 'string'
41
+ };
42
+ return map[type] || 'string';
43
+ });
44
+
45
+ Handlebars.registerHelper('toGoCast', (type) => {
46
+ const isEnum = enums.some(e => e.name === type);
47
+ if (isEnum) return `models.${type}`;
48
+
49
+ const map = {
50
+ 'Integer': 'int',
51
+ 'Long': 'int64',
52
+ 'Float': 'float64',
53
+ 'BigDecimal': 'float64',
54
+ 'Boolean': 'bool',
55
+ 'JSON': 'datatypes.JSON',
56
+ 'JSONB': 'datatypes.JSON'
57
+ };
58
+ return map[type] || '';
59
+ });
60
+
61
+ Handlebars.registerHelper('toProtoCast', (type) => {
62
+ const isEnum = enums.some(e => e.name === type);
63
+ if (isEnum) return 'string';
64
+
65
+ const map = {
66
+ 'Integer': 'int32',
67
+ 'Long': 'int64',
68
+ 'Float': 'float32',
69
+ 'BigDecimal': 'double',
70
+ 'JSON': 'string',
71
+ 'JSONB': 'string'
72
+ };
73
+ return map[type] || '';
74
+ });
75
+
76
+ Handlebars.registerHelper('add', (a, b) => {
77
+ return a + b;
78
+ });
79
+
80
+ Handlebars.registerHelper('hasJson', (fields) => {
81
+ if (!fields || !Array.isArray(fields)) return false;
82
+ return fields.some(f => f.type === 'JSON' || f.type === 'JSONB');
83
+ });
84
+
85
+ Handlebars.registerHelper('isJson', (type) => type === 'JSON' || type === 'JSONB');
86
+
87
+ for (const entity of entities) {
88
+ const context = {
89
+ name: entity.name,
90
+ capitalize: (s) => s.charAt(0).toUpperCase() + s.slice(1),
91
+ lower: (s) => s.toLowerCase(),
92
+ fields: entity.fields,
93
+ projectName,
94
+ enums
95
+ };
96
+
97
+ // 1. Generate Proto
98
+ const protoContent = protoTemplate(context);
99
+ await fs.writeFile(path.join(apiDir, `${entity.name.toLowerCase()}.proto`), protoContent);
100
+
101
+ // 2. Generate Service Implementation
102
+ const serviceContent = serviceTemplate(context);
103
+ await fs.writeFile(path.join(serviceDir, `${entity.name.toLowerCase()}.go`), serviceContent);
104
+ }
105
+
106
+ // 3. Generate Auth Middleware & gRPC Server with Kratos
107
+ await generateKratosServer(serverDir, projectName, entities);
108
+
109
+ console.log(chalk.green('✅ Kratos gRPC code generated successfully!'));
110
+ };
111
+
112
+ const generateKratosServer = async (serverDir, projectName, entities) => {
113
+ const grpcServerTemplate = `package server
114
+
115
+ import (
116
+ "context"
117
+ "github.com/go-kratos/kratos/v2/middleware/auth/jwt"
118
+ "github.com/go-kratos/kratos/v2/middleware/recovery"
119
+ "github.com/go-kratos/kratos/v2/transport/grpc"
120
+ "github.com/golang-jwt/jwt/v4"
121
+ v1 "{{projectName}}/api/v1"
122
+ "{{projectName}}/internal/service"
123
+ "{{projectName}}/internal/repository"
124
+ "{{projectName}}/config"
125
+ )
126
+
127
+ func NewGRPCServer(conf *config.Config, repo *repository.Repository) *grpc.Server {
128
+ var opts = []grpc.ServerOption{
129
+ grpc.Middleware(
130
+ recovery.Recovery(),
131
+ jwt.Server(func(token *jwt.Token) (interface{}, error) {
132
+ return []byte(conf.GoDuck.Security.KeycloakSecret), nil
133
+ }),
134
+ ),
135
+ }
136
+ if conf.GoDuck.Server.GRPC.Addr != "" {
137
+ opts = append(opts, grpc.Address(conf.GoDuck.Server.GRPC.Addr))
138
+ }
139
+ srv := grpc.NewServer(opts...)
140
+
141
+ // Register Services
142
+ {{#each entities}}
143
+ v1.Register{{capitalize name}}ServiceServer(srv, service.New{{capitalize name}}Service(repo))
144
+ {{/each}}
145
+ // go-duck-needle-add-grpc-service
146
+
147
+ return srv
148
+ }
149
+ `;
150
+ const template = Handlebars.compile(grpcServerTemplate);
151
+ const content = template({
152
+ projectName,
153
+ entities,
154
+ capitalize: (s) => s.charAt(0).toUpperCase() + s.slice(1)
155
+ });
156
+ await fs.writeFile(path.join(serverDir, 'grpc.go'), content);
157
+ };
@@ -0,0 +1,68 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+
5
+ export const generateLoggerCode = async (config, outputDir) => {
6
+ const loggerDir = path.join(outputDir, 'logger');
7
+ await fs.ensureDir(loggerDir);
8
+
9
+ const loggerGo = `
10
+ package logger
11
+
12
+ import (
13
+ "log"
14
+ "os"
15
+ "{{app_name}}/config"
16
+
17
+ "github.com/DataDog/datadog-go/statsd"
18
+ )
19
+
20
+ var (
21
+ Statsd *statsd.Client
22
+ )
23
+
24
+ // InitLogger initializes the application logging and monitoring
25
+ func InitLogger(cfg *config.Config) {
26
+ if cfg.GoDuck.Logging.Datadog.Enabled {
27
+ log.Printf("Initializing Datadog Monitoring for service: %s", cfg.GoDuck.Logging.Datadog.Service)
28
+
29
+ // In a real implementation, you'd use a Datadog logging hook or library
30
+ // Here we initialize Statsd as an example of DD integration
31
+ client, err := statsd.New("127.0.0.1:8125")
32
+ if err == nil {
33
+ Statsd = client
34
+ Statsd.Namespace = cfg.GoDuck.Logging.Datadog.Service + "."
35
+ Statsd.Tags = []string{"environment:" + cfg.Environment.ActiveProfile}
36
+ } else {
37
+ log.Printf("Warning: Failed to initialize Datadog statsd: %v", err)
38
+ }
39
+ } else {
40
+ log.Println("Datadog logging is disabled. Using standard console output.")
41
+ }
42
+
43
+ // Set standard logger output
44
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
45
+ log.SetOutput(os.Stdout)
46
+ }
47
+
48
+ // Info logs information messages
49
+ func Info(format string, v ...interface{}) {
50
+ log.Printf("[INFO] "+format, v...)
51
+ }
52
+
53
+ // Error logs error messages
54
+ func Error(format string, v ...interface{}) {
55
+ log.Printf("[ERROR] "+format, v...)
56
+ }
57
+
58
+ // Trace metric (example of DD analytics)
59
+ func TraceMetric(name string, value float64, tags []string) {
60
+ if Statsd != nil {
61
+ Statsd.Gauge(name, value, tags, 1)
62
+ }
63
+ }
64
+ `;
65
+
66
+ await fs.writeFile(path.join(loggerDir, 'logger.go'), loggerGo.replace(/{{app_name}}/g, config.name));
67
+ console.log(chalk.gray(' Generated Datadog-ready Logger Package'));
68
+ };
@@ -0,0 +1,143 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+
5
+ export const generateMeteringCode = async (config, outputDir) => {
6
+ const middlewareDir = path.join(outputDir, 'middleware');
7
+ const modelsDir = path.join(outputDir, 'models');
8
+ const controllersDir = path.join(outputDir, 'controllers');
9
+
10
+ await fs.ensureDir(middlewareDir);
11
+ await fs.ensureDir(modelsDir);
12
+ await fs.ensureDir(controllersDir);
13
+
14
+ const meteringModel = `
15
+ package models
16
+
17
+ import (
18
+ "time"
19
+ )
20
+
21
+ type APIUsage struct {
22
+ ID uint \`gorm:"primaryKey" json:"id"\`
23
+ UserID string \`json:"userId" gorm:"index:idx_user_api,unique"\`
24
+ APIPath string \`json:"apiPath" gorm:"index:idx_user_api,unique"\`
25
+ UsageCount int64 \`json:"usageCount"\`
26
+ MaxLimit int64 \`json:"maxLimit" gorm:"default:1000"\`
27
+ LastAccessed time.Time \`json:"lastAccessed"\`
28
+ }
29
+ `;
30
+
31
+ const meteringMiddleware = `
32
+ package middleware
33
+
34
+ import (
35
+ "net/http"
36
+ "time"
37
+
38
+ "github.com/gin-gonic/gin"
39
+ "gorm.io/gorm"
40
+ "{{app_name}}/models"
41
+ )
42
+
43
+ func MeteringMiddleware(db *gorm.DB) gin.HandlerFunc {
44
+ return func(c *gin.Context) {
45
+ userID := c.GetHeader("X-Keycloak-Id")
46
+ if userID == "" {
47
+ c.Next()
48
+ return
49
+ }
50
+
51
+ path := c.Request.URL.Path
52
+ var usage models.APIUsage
53
+
54
+ // Get usage and limit
55
+ result := db.Where("user_id = ? AND api_path = ?", userID, path).First(&usage)
56
+ if result.Error == gorm.ErrRecordNotFound {
57
+ usage = models.APIUsage{
58
+ UserID: userID,
59
+ APIPath: path,
60
+ UsageCount: 1,
61
+ MaxLimit: 1000, // Default limit
62
+ LastAccessed: time.Now(),
63
+ }
64
+ db.Create(&usage)
65
+ } else {
66
+ if usage.UsageCount >= usage.MaxLimit {
67
+ c.JSON(http.StatusTooManyRequests, gin.H{
68
+ "error": "Usage limit exceeded",
69
+ "limit": usage.MaxLimit,
70
+ "usage": usage.UsageCount,
71
+ })
72
+ c.Abort()
73
+ return
74
+ }
75
+ db.Model(&usage).Updates(map[string]interface{}{
76
+ "usage_count": usage.UsageCount + 1,
77
+ "last_accessed": time.Now(),
78
+ })
79
+ }
80
+
81
+ c.Next()
82
+ }
83
+ }
84
+ `;
85
+
86
+ const meteringController = `
87
+ package controllers
88
+
89
+ import (
90
+ "net/http"
91
+ "{{app_name}}/models"
92
+
93
+ "github.com/gin-gonic/gin"
94
+ "gorm.io/gorm"
95
+ )
96
+
97
+ type MeteringController struct {
98
+ DB *gorm.DB
99
+ }
100
+
101
+ func (mc *MeteringController) SetLimit(c *gin.Context) {
102
+ var req struct {
103
+ UserID string \`json:"userId" binding:"required"\`
104
+ APIPath string \`json:"apiPath" binding:"required"\`
105
+ MaxLimit int64 \`json:"maxLimit" binding:"required"\`
106
+ }
107
+
108
+ if err := c.ShouldBindJSON(&req); err != nil {
109
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
110
+ return
111
+ }
112
+
113
+ var usage models.APIUsage
114
+ result := mc.DB.Where("user_id = ? AND api_path = ?", req.UserID, req.APIPath).First(&usage)
115
+
116
+ if result.Error == gorm.ErrRecordNotFound {
117
+ usage = models.APIUsage{
118
+ UserID: req.UserID,
119
+ APIPath: req.APIPath,
120
+ MaxLimit: req.MaxLimit,
121
+ UsageCount: 0,
122
+ }
123
+ mc.DB.Create(&usage)
124
+ } else {
125
+ mc.DB.Model(&usage).Update("max_limit", req.MaxLimit)
126
+ }
127
+
128
+ c.JSON(http.StatusOK, gin.H{"message": "Limit updated successfully"})
129
+ }
130
+
131
+ func (mc *MeteringController) GetUsage(c *gin.Context) {
132
+ var usages []models.APIUsage
133
+ mc.DB.Find(&usages)
134
+ c.JSON(http.StatusOK, usages)
135
+ }
136
+ `;
137
+
138
+ await fs.writeFile(path.join(modelsDir, 'api_usage.go'), meteringModel);
139
+ await fs.writeFile(path.join(middlewareDir, 'metering_middleware.go'), meteringMiddleware.replace('{{app_name}}', config.name));
140
+ await fs.writeFile(path.join(controllersDir, 'metering_controller.go'), meteringController.replace('{{app_name}}', config.name));
141
+
142
+ console.log(chalk.gray(' Generated Metering Model, Middleware & Controller'));
143
+ };
@@ -0,0 +1,240 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { toLiquibaseType } from '../parser/gdl.js';
5
+
6
+ export const generateLiquibaseChangelogs = async (entities, relationships, projectRootDir, delta = null, enums = []) => {
7
+ const migrationsDir = path.join(projectRootDir, 'migrations');
8
+ const liquibaseDir = path.join(migrationsDir, 'liquibase');
9
+ const changelogsDir = path.join(liquibaseDir, 'changelogs');
10
+ await fs.ensureDir(migrationsDir);
11
+ await fs.ensureDir(liquibaseDir);
12
+ await fs.ensureDir(changelogsDir);
13
+
14
+ const timestamp = new Date().toISOString().replace(/[-:T]/g, '').split('.')[0];
15
+ const dateStamp = timestamp.substring(0, 8); // YYYYMMDD
16
+
17
+ // -------------------------------------------------------
18
+ // STEP 1: Build changesets FIRST — decide on filename after
19
+ // -------------------------------------------------------
20
+ let changeSets = '';
21
+
22
+ const entitiesToCreate = delta ? delta.newEntities : entities;
23
+ const relationshipsToAdd = delta ? delta.newRelationships : relationships;
24
+
25
+ // Create New Tables
26
+ for (const entity of entitiesToCreate) {
27
+ let columns = `
28
+ <column name="id" type="BIGINT" autoIncrement="true">
29
+ <constraints primaryKey="true" nullable="false"/>
30
+ </column>`;
31
+
32
+ for (const field of entity.fields) {
33
+ const liqType = toLiquibaseType(field, enums);
34
+ const uniqueConstraint = field.unique ? ' <constraints nullable="' + (field.required ? 'false' : 'true') + '" unique="true"/>' : '';
35
+ if (field.unique) {
36
+ columns += `
37
+ <column name="${field.name.toLowerCase()}" type="${liqType}">
38
+ <constraints nullable="${field.required ? 'false' : 'true'}" unique="true"/>
39
+ </column>`;
40
+ } else {
41
+ columns += `
42
+ <column name="${field.name.toLowerCase()}" type="${liqType}">
43
+ <constraints nullable="${field.required ? 'false' : 'true'}"/>
44
+ </column>`;
45
+ }
46
+ }
47
+
48
+ // @Audited entities get full audit columns (skip the simpler created_at/updated_at to avoid duplicates)
49
+ if (entity.annotation === '@Audited') {
50
+ columns += `
51
+ <column name="created_by" type="VARCHAR(255)"/>
52
+ <column name="created_date" type="TIMESTAMP"/>
53
+ <column name="last_modified_by" type="VARCHAR(255)"/>
54
+ <column name="last_modified_date" type="TIMESTAMP"/>
55
+ <column name="last_modified_user_id" type="VARCHAR(255)"/>`;
56
+ } else {
57
+ columns += `
58
+ <column name="created_at" type="TIMESTAMP"/>
59
+ <column name="updated_at" type="TIMESTAMP"/>`;
60
+ }
61
+
62
+ changeSets += `
63
+ <changeSet id="${entity.name.toLowerCase()}-create-${timestamp}" author="go-duck">
64
+ <preConditions onFail="MARK_RAN"><not><tableExists tableName="${entity.name.toLowerCase()}"/></not></preConditions>
65
+ <createTable tableName="${entity.name.toLowerCase()}">
66
+ ${columns}
67
+ </createTable>
68
+ </changeSet>
69
+ `;
70
+ }
71
+
72
+ // Add New Fields to existing tables
73
+ if (delta && delta.newFields) {
74
+ for (const [entityName, fields] of Object.entries(delta.newFields)) {
75
+ if (fields.length === 0) continue;
76
+ let columnTags = '';
77
+ for (const field of fields) {
78
+ const liqType = toLiquibaseType(field, enums);
79
+ if (field.unique) {
80
+ columnTags += `
81
+ <column name="${field.name.toLowerCase()}" type="${liqType}">
82
+ <constraints nullable="${field.required ? 'false' : 'true'}" unique="true"/>
83
+ </column>`;
84
+ } else {
85
+ columnTags += `
86
+ <column name="${field.name.toLowerCase()}" type="${liqType}">
87
+ <constraints nullable="${field.required ? 'false' : 'true'}"/>
88
+ </column>`;
89
+ }
90
+ }
91
+ changeSets += `
92
+ <changeSet id="${entityName.toLowerCase()}-add-fields-${timestamp}" author="go-duck">
93
+ <addColumn tableName="${entityName.toLowerCase()}">
94
+ ${columnTags}
95
+ </addColumn>
96
+ </changeSet>
97
+ `;
98
+ }
99
+ }
100
+
101
+ // Foreign Keys + Index
102
+ for (const rel of relationshipsToAdd) {
103
+ if (rel.type === 'OneToMany') {
104
+ const childTable = rel.to.entity.toLowerCase();
105
+ const fkNullable = rel.required ? 'false' : 'true';
106
+ const fkCol = rel.to.field.toLowerCase() + '_id';
107
+ const parentTable = rel.from.entity.toLowerCase();
108
+ const fkName = `fk_${childTable}_${rel.to.field.toLowerCase()}`;
109
+ const idxName = `idx_${childTable}_${fkCol}`;
110
+ changeSets += `
111
+ <changeSet id="rel-${parentTable}-${childTable}-${timestamp}" author="go-duck">
112
+ <preConditions onFail="MARK_RAN">
113
+ <not>
114
+ <columnExists tableName="${childTable}" columnName="${fkCol}"/>
115
+ </not>
116
+ </preConditions>
117
+ <addColumn tableName="${childTable}">
118
+ <column name="${fkCol}" type="BIGINT">
119
+ <constraints nullable="${fkNullable}" foreignKeyName="${fkName}" referencedTableName="${parentTable}" referencedColumnNames="id"/>
120
+ </column>
121
+ </addColumn>
122
+ </changeSet>
123
+
124
+ <changeSet id="idx-${idxName}-${timestamp}" author="go-duck">
125
+ <preConditions onFail="MARK_RAN">
126
+ <not><indexExists indexName="${idxName}"/></not>
127
+ </preConditions>
128
+ <createIndex indexName="${idxName}" tableName="${childTable}">
129
+ <column name="${fkCol}"/>
130
+ </createIndex>
131
+ </changeSet>
132
+ `;
133
+ }
134
+ }
135
+
136
+ // Support Tables (always on initial run, never on incremental)
137
+ if (!delta) {
138
+ changeSets += `
139
+ <changeSet id="management-tables-init" author="go-duck">
140
+ <preConditions onFail="MARK_RAN"><not><tableExists tableName="tenant_roles"/></not></preConditions>
141
+ <createTable tableName="tenant_roles">
142
+ <column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
143
+ <column name="role_name" type="VARCHAR(255)"><constraints nullable="false" unique="true"/></column>
144
+ <column name="db_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
145
+ </createTable>
146
+ </changeSet>
147
+
148
+ <changeSet id="audit-log-table-init" author="go-duck">
149
+ <preConditions onFail="MARK_RAN"><not><tableExists tableName="audit_log"/></not></preConditions>
150
+ <createTable tableName="audit_log">
151
+ <column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
152
+ <column name="entity_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
153
+ <column name="entity_id" type="VARCHAR(255)"><constraints nullable="false"/></column>
154
+ <column name="action" type="VARCHAR(50)"><constraints nullable="false"/></column>
155
+ <column name="previous_value" type="TEXT"/><column name="new_value" type="TEXT"/>
156
+ <column name="modified_by" type="VARCHAR(255)"/><column name="keycloak_id" type="VARCHAR(255)"/>
157
+ <column name="modified_at" type="TIMESTAMP"><constraints nullable="false"/></column>
158
+ <column name="client_ip" type="VARCHAR(50)"/>
159
+ </createTable>
160
+ </changeSet>
161
+
162
+ <changeSet id="api-usage-table-init" author="go-duck">
163
+ <preConditions onFail="MARK_RAN"><not><tableExists tableName="api_usage"/></not></preConditions>
164
+ <createTable tableName="api_usage">
165
+ <column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
166
+ <column name="user_id" type="VARCHAR(255)"><constraints nullable="false"/></column>
167
+ <column name="api_path" type="VARCHAR(255)"><constraints nullable="false"/></column>
168
+ <column name="usage_count" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
169
+ <column name="max_limit" type="BIGINT" defaultValueNumeric="1000"><constraints nullable="false"/></column>
170
+ <column name="last_accessed" type="TIMESTAMP"/>
171
+ </createTable>
172
+ </changeSet>
173
+ `;
174
+ }
175
+
176
+ // -------------------------------------------------------
177
+ // STEP 2: Only write files if there are real changesets
178
+ // -------------------------------------------------------
179
+ if (changeSets.trim() === '') {
180
+ console.log(chalk.gray(' No database changes detected.'));
181
+ return;
182
+ }
183
+
184
+ // Build descriptive filename from what actually changed
185
+ const descParts = [];
186
+ if (!delta) {
187
+ const entityNames = entities.map(e => e.name.toLowerCase()).join('-');
188
+ descParts.push('init');
189
+ if (entityNames) descParts.push(entityNames);
190
+ } else {
191
+ if (delta.newEntities && delta.newEntities.length > 0)
192
+ descParts.push('create-' + delta.newEntities.map(e => e.name.toLowerCase()).join('-'));
193
+ if (delta.newFields && Object.keys(delta.newFields).length > 0)
194
+ descParts.push('add-fields-to-' + Object.keys(delta.newFields).map(n => n.toLowerCase()).join('-'));
195
+ if (delta.newRelationships && delta.newRelationships.length > 0)
196
+ descParts.push('add-relations');
197
+ }
198
+
199
+ const changelogFileName = `changelog-${dateStamp}-${descParts.join('__')}.xml`;
200
+ const changelogPath = path.join(changelogsDir, changelogFileName);
201
+ const masterPath = path.join(liquibaseDir, 'master.xml');
202
+
203
+ // Write the changelog XML
204
+ const changelogXml = `<?xml version="1.0" encoding="UTF-8"?>
205
+ <databaseChangeLog
206
+ xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
207
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
208
+ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
209
+ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
210
+ ${changeSets}
211
+ </databaseChangeLog>
212
+ `;
213
+ await fs.writeFile(changelogPath, changelogXml);
214
+
215
+ // Register in master.xml (only now that the file exists)
216
+ let masterXml = '';
217
+ if (await fs.pathExists(masterPath)) {
218
+ masterXml = await fs.readFile(masterPath, 'utf8');
219
+ if (!masterXml.includes(changelogFileName)) {
220
+ const includeLine = ` <include file="changelogs/${changelogFileName}" relativeToChangelogFile="true"/>`;
221
+ masterXml = masterXml.replace('</databaseChangeLog>', `${includeLine}\n</databaseChangeLog>`);
222
+ }
223
+ } else {
224
+ masterXml = `<?xml version="1.0" encoding="UTF-8"?>
225
+ <databaseChangeLog
226
+ xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
227
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
228
+ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
229
+ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
230
+
231
+ <include file="changelogs/${changelogFileName}" relativeToChangelogFile="true"/>
232
+ </databaseChangeLog>
233
+ `;
234
+ }
235
+ await fs.writeFile(masterPath, masterXml);
236
+
237
+ console.log(chalk.gray(` Generated Incremental Changelog: ${changelogFileName}`));
238
+ };
239
+
240
+ const _unused = null; // toLiquibaseType imported from parser/gdl.js