go-duck-cli 1.2.0 β†’ 1.2.2

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 CHANGED
@@ -43,9 +43,9 @@ But speed without strength is a house made of cards. In the digital forge of the
43
43
 
44
44
  Thus, the **GDL (Go-Duck Language)** was hatched. A single, simple tongue that could command entire legions of code. From that day forth, every developer who whispered GDL into the CLI would see their architecture evolveβ€”bringing the Gopher's speed, the Duck's wisdom, the Gin's clarity, and the Kratos' strength into a single, unified masterpiece.
45
45
 
46
- ## πŸ¦† The 375% Elite Status: Milestone Surpassed
46
+ ## πŸ¦† The 410% Elite Status: Milestone Surpassed
47
47
 
48
- GO-DUCK has officially reached the **375% Achievement Status**, evolving from a code generator into a **High-Velocity Distributed Orchestrator.** This elite status confirms that the framework has surpassed the original 350% milestone with industrial-grade Universal Storage extensions and real-time remote bootstrapping.
48
+ GO-DUCK has officially reached the **410% Achievement Status**, evolving from a code generator into a **High-Velocity Distributed Orchestrator.** This elite status confirms that the framework has surpassed the original 350% milestone with industrial-grade Universal Storage extensions, real-time remote bootstrapping, WSO2 integration, and full-spectrum GDL evolution.
49
49
 
50
50
  | Milestone Component | Status | Technical Value Add |
51
51
  | :--- | :--- | :--- |
@@ -57,11 +57,15 @@ GO-DUCK has officially reached the **375% Achievement Status**, evolving from a
57
57
  | **Super Admin Security Boundary** | πŸš€ **ELITE (+13%)** | Strict isolation between Business and Infrastructure Control APIs. |
58
58
  | **Silo Discovery & Privacy Proxy** | πŸš€ **ELITE (+10%)** | Silo discovery API with physical DB name masking. |
59
59
  | **Universal Storage Mesh** | πŸš€ **ELITE (+25%)** | Dynamic Hot-Swapping Registry and Distributed Cross-Scan API retrieval. |
60
- | **TOTAL ACHIEVEMENT STATUS** | πŸ† **375%** | **ELITE STATUS CONFIRMED.** πŸ‘‘ |
60
+ | **WSO2 API Gateway Integration** | πŸš€ **ELITE (+15%)** | Automated OpenAPI registration & proxy mapping. |
61
+ | **Full-Spectrum GDL Evolution** | πŸš€ **ELITE (+15%)** | Native DROP/ALTER migrations with dead-code purging. |
62
+ | **TOTAL ACHIEVEMENT STATUS** | πŸ† **410%** | **ELITE STATUS CONFIRMED.** πŸ‘‘ |
61
63
 
62
- ### ✨ Primary Features (The 375% Core)
64
+ ### ✨ Primary Features (The 410% Core)
63
65
 
64
66
  * **Federated Multi-Tenancy**: Side-by-side **Database-per-Tenant isolation** with a **Master-Tenant Registry** (Role ↔ DB ↔ Opaque UUID).
67
+ * **Multi-Protocol REST**: Dynamically switchable REST rendering using standard `json` or high-performance `messagepack` based on configuration.
68
+ * **gRPC-Web Native Proxy**: Natively wraps Kratos gRPC to automatically serve a web proxy (e.g. on port `9090`) to allow direct frontend Protobuf interaction.
65
69
  * **Industrial-Grade Parallel Harvester**: Asynchronous goroutine-based data aggregation across multiple silos with `?federated=true` opt-in.
66
70
  * **Precision Harvesting**: Surgical multi-silo selection via comma-separated `X-Tenant-ID` headers.
67
71
  * **Super Admin Security Boundaries**: Strict architectural separation between Business APIs and Infrastructure Control APIs.
@@ -70,9 +74,12 @@ GO-DUCK has officially reached the **375% Achievement Status**, evolving from a
70
74
  * **Distributed Saga Consistency**: Integrated **Transactional Outbox** pattern and background workers in every silo to guarantee eventual consistency across the federation.
71
75
  * **Zero-Trust Identity Registry**: Decoupled mapping layer ensuring physical database names and internal IDs never leak to the client.
72
76
  * **Universal Storage Mesh**: Dynamic Multi-Provider Registry allowing hot-swapping at runtime via `?provider=` queries, alongside Distributed Cross-Scan endpoints to auto-locate files across AWS, GCS, SFTP, and GitHub lakes.
73
- * **Spring-style Elasticsearch Search**: Real-time sync for entities marked with `@Searchable`, supporting fuzzy matching and complex queries.
77
+ * **Spring-style Elasticsearch Search**: Real-time sync for entities marked with `@Searchable`, supporting native `query_string` syntax (wildcards like `*`, booleans, ranges, and fuzzy matching).
74
78
  * **SaaS Quota Engine**: Redis-backed API bandwidth tracking with dynamic, hierarchical limits (User vs. Role mapping).
75
79
  * **Resilience Layer**: Sony/Gobreaker Integration + Zero-Trust Distributed Redis Rate Limiter.
80
+ * **Comprehensive GDL Schema Evolution**: Detects structural deltas intelligently and automatically generates Goose SQL for dropped entities (`DROP TABLE`), dropped fields (`DROP COLUMN`), and altered fields (`ALTER TYPE`, constraints) to keep databases in absolute sync.
81
+ * **Dead-Code Purging**: Automatically cleans up orphaned Go models and controllers when removing entities from the `.gdl` blueprint.
82
+ * **Saga Outbox GORM Stability**: Generates robust, compile-ready `outbox_worker.go` components with native GORM package resolution.
76
83
 
77
84
  ### πŸ—ΊοΈ System Topology & Identity Registry
78
85
 
@@ -151,6 +158,37 @@ docker-compose up -d
151
158
  go run main.go
152
159
  ```
153
160
 
161
+ ## 🌐 Calling the Multi-Protocol Endpoints
162
+
163
+ GO-DUCK automatically exposes your microservice across three distinct ports to serve different architectural needs without code duplication:
164
+
165
+ 1. **REST API (Port 8080 by default)**
166
+ Standard HTTP endpoints for web browsers and legacy clients.
167
+ - **JSON (Default):** Perfect for general use.
168
+ - **MessagePack (High Performance):** To switch to binary MessagePack, update your `config.yaml` before generation:
169
+ ```yaml
170
+ server:
171
+ rest:
172
+ port: 8080
173
+ protocol: "messagepack"
174
+ ```
175
+ Clients must send the `Accept: application/msgpack` and `Content-Type: application/msgpack` headers to consume this endpoint correctly.
176
+ - **Pagination & Sorting:** All `GetAll` endpoints support pagination via `?page=1&size=20`, and dynamic sorting via `?sort=field,asc` or `?sort=field,desc` (e.g. `?sort=name,desc`).
177
+
178
+ 2. **Native Kratos gRPC (Port 9000 by default)**
179
+ Pure HTTP/2 Protobuf communication. Used strictly for internal **backend-to-backend** communication (e.g., Microservice A calling Microservice B) where maximum throughput is required.
180
+
181
+ 3. **gRPC-Web Proxy (Port 9090 by default)**
182
+ Web browsers cannot speak raw HTTP/2 gRPC. This port acts as an HTTP/1.1 proxy bridge, allowing frontend frameworks (React, Angular, Vue) to interact directly with the Protobuf gRPC contracts.
183
+ To disable or change the proxy port, update your `config.yaml`:
184
+ ```yaml
185
+ server:
186
+ grpc:
187
+ web_enabled: true
188
+ web_port: 9090
189
+ ```
190
+ *Frontend Tip: Use the `grpc-web` npm package to generate a frontend client that communicates with this port!*
191
+
154
192
  ## Usage
155
193
 
156
194
  The `go-duck-cli` has two main commands: `create` and `import-gdl`.
@@ -52,7 +52,7 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
52
52
  for (const entity of entities) {
53
53
  const routeName = entity.name.toLowerCase() + 's';
54
54
  endpointsContent += `\n#### ${entity.name}\n`;
55
- endpointsContent += `- \`GET /api/${routeName}\` (Pagination, e.g. \`?page=1&size=10&eager=true\`)\n`;
55
+ endpointsContent += `- \`GET /api/${routeName}\` (Pagination & dynamic sorting, e.g. \`?page=1&size=10&eager=true&sort=id,asc\`)\n`;
56
56
  endpointsContent += `- \`GET /api/${routeName}/:id\`\n`;
57
57
  endpointsContent += `- \`POST /api/${routeName}\`\n`;
58
58
  endpointsContent += `- \`PUT /api/${routeName}/:id\`\n`;
@@ -88,6 +88,7 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
88
88
  if (entity.isSearchable) entitiesContent += `- \`@Searchable\`\n`;
89
89
  if (entity.isAudited) entitiesContent += `- \`@Audited\`\n`;
90
90
  if (entity.isFederated) entitiesContent += `- \`@Federated\`\n`;
91
+ if (entity.isDelete) entitiesContent += `- \`@Delete\`\n`;
91
92
 
92
93
  entitiesContent += `\nFields:\n`;
93
94
  for (const field of entity.fields) {
@@ -101,16 +102,20 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
101
102
  // 4. PROTOCOLS.md
102
103
  let protoContent = `# Additional Network Protocols\n\n`;
103
104
  protoContent += `## GraphQL Surface\n`;
104
- protoContent += `- **Endpoint**: \`POST /query\`\n`;
105
- protoContent += `- **Playground**: \`GET /\`\n\n`;
105
+ protoContent += `- **Endpoint**: \`POST /graphql\` (Secured via JWT and Tenant headers)\n`;
106
+ protoContent += `- **Playground**: GraphQL Playground UI is enabled on the server.\n`;
107
+ protoContent += `- **Queries**: \`list[Entity]s(page: Int, size: Int)\`, \`get[Entity](id: ID!)\`\n`;
108
+ protoContent += `- **Mutations**: \`create[Entity](input: CreateInput)\`, \`update[Entity](id: ID!, input: UpdateInput)\`, \`delete[Entity](id: ID!)\`\n`;
109
+ protoContent += `- **Postman Collection Integration**: The auto-generated Postman collection includes a comprehensive **GraphQL Federation Layer** folder containing test queries and mutations for all entities.\n\n`;
106
110
 
107
111
  protoContent += `## WebSocket Engine\n`;
108
- protoContent += `- **Endpoint**: \`ws[s]://host/ws\`\n`;
109
- protoContent += `- **Format**: JSON Envelope Payload \`{ "type": "...", "payload": {...} }\`\n\n`;
112
+ protoContent += `- **Endpoint**: \`ws[s]://host/ws?token={JWT}\`\n`;
113
+ protoContent += `- **Format**: JSON Envelope Payload with HMAC-SHA256 digital signature: \`{ "type": "...", "payload": {...} }\`\n\n`;
110
114
 
111
- protoContent += `## gRPC (Kratos) Engine\n`;
112
- protoContent += `- **Port**: \`9000\` (Default)\n`;
113
- protoContent += `- **Proto Definitions**: Located in \`api/.../\`\n`;
115
+ protoContent += `## gRPC & gRPC-Web Engine\n`;
116
+ protoContent += `- **Native Kratos gRPC Port**: \`9000\` (TCP)\n`;
117
+ protoContent += `- **gRPC-Web Proxy Port**: \`9090\` (HTTP/1.1 for web clients)\n`;
118
+ protoContent += `- **Proto Definitions**: Located in \`api/v1/*.proto\`\n`;
114
119
 
115
120
  await fs.writeFile(path.join(aiDocsDir, 'PROTOCOLS.md'), protoContent);
116
121
 
@@ -118,6 +123,14 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
118
123
  let agentInstructions = `# LLM / AI AGENT INSTRUCTIONS πŸ€–\n\n`;
119
124
  agentInstructions += `Welcome to the generated codebase for **${appName}**.\n\n`;
120
125
  agentInstructions += `This application was generated by the GO-DUCK-CLI (An advanced Evolutionary Go Code Generator).\n\n`;
126
+ agentInstructions += `### High-Velocity Commands:\n`;
127
+ agentInstructions += `- **Build Project**: \`go build ./...\`\n`;
128
+ agentInstructions += `- **Protobuf Compilation**: \`./generate.sh\` (or \`.\\generate.bat\` on Windows)\n`;
129
+ agentInstructions += `- **Local Dependencies & Dev Boot**: \`docker-compose up -d && go run main.go\`\n\n`;
130
+ agentInstructions += `### GDL Evolution & Schema Deletions:\n`;
131
+ agentInstructions += `- **Snapshot Merging (Multi-File GDL)**: The generator implements stateful snapshot merging. Running \`import-gdl\` on a single GDL file will NOT wipe out other entities; previous snapshots are retrieved from the \`.go-duck/\` state folder and merged automatically.\n`;
132
+ agentInstructions += `- **Altering/Dropping Fields**: To add, drop, or edit fields within an entity, update the fields inline in the GDL entity block. Running \`import-gdl\` generates targeted Goose SQL column-level migrations.\n`;
133
+ agentInstructions += `- **Dropping Entities**: To completely delete an entity, its database table, all generated code files, and its snapshot, append the \`@Delete\` annotation above the entity definition block and run \`import-gdl\`.\n\n`;
121
134
  agentInstructions += `### How to navigate this system:\n`;
122
135
  agentInstructions += `The full system specifications and dynamically generated blueprints have been exported for you in the \`docs/ai/\` folder. Look there before attempting any code modifications.\n\n`;
123
136
  agentInstructions += `- **[docs/ai/ARCHITECTURE.md](docs/ai/ARCHITECTURE.md)**: Describes the databases, caching layers, configuration structure, and middleware enabled in this project.\n`;
@@ -25,13 +25,16 @@ type Config struct {
25
25
  Description string \`mapstructure:"description"\`
26
26
 
27
27
  Server struct {
28
- Port int \`mapstructure:"port"\`
29
- ReadTimeout time.Duration \`mapstructure:"read-timeout"\`
30
- WriteTimeout time.Duration \`mapstructure:"write-timeout"\`
28
+ REST struct {
29
+ Port int \`mapstructure:"port"\`
30
+ Protocol string \`mapstructure:"protocol"\`
31
+ } \`mapstructure:"rest"\`
31
32
  GRPC struct {
32
- Addr string \`mapstructure:"addr"\`
33
- Network string \`mapstructure:"network"\`
34
- Timeout time.Duration \`mapstructure:"timeout"\`
33
+ Addr string \`mapstructure:"addr"\`
34
+ Network string \`mapstructure:"network"\`
35
+ Timeout time.Duration \`mapstructure:"timeout"\`
36
+ WebEnabled bool \`mapstructure:"web_enabled"\`
37
+ WebPort int \`mapstructure:"web_port"\`
35
38
  } \`mapstructure:"grpc"\`
36
39
  CORS struct {
37
40
  AllowOrigins []string \`mapstructure:"allow-origins"\`
@@ -237,7 +240,8 @@ func LoadConfig() (*Config, error) {
237
240
  v.AddConfigPath(".")
238
241
 
239
242
  // Default values
240
- v.SetDefault("go-duck.server.port", 8080)
243
+ v.SetDefault("go-duck.server.rest.port", 8080)
244
+ v.SetDefault("go-duck.server.rest.protocol", "json")
241
245
  v.SetDefault("go-duck.security.rate-limit.rps", 100.0)
242
246
  v.SetDefault("go-duck.security.rate-limit.burst", 200)
243
247
  v.SetDefault("go-duck.logging.datadog.enabled", false)
@@ -254,6 +258,8 @@ func LoadConfig() (*Config, error) {
254
258
  v.SetDefault("go-duck.server.grpc.addr", ":9000")
255
259
  v.SetDefault("go-duck.server.grpc.network", "tcp")
256
260
  v.SetDefault("go-duck.server.grpc.timeout", "1s")
261
+ v.SetDefault("go-duck.server.grpc.web_enabled", false)
262
+ v.SetDefault("go-duck.server.grpc.web_port", 9090)
257
263
  v.SetDefault("go-duck.resilience.circuit-breaker.enabled", true)
258
264
  v.SetDefault("go-duck.resilience.circuit-breaker.failure-threshold", 5)
259
265
  v.SetDefault("go-duck.resilience.circuit-breaker.timeout", "60s")
@@ -156,10 +156,9 @@ func ExecuteSearch(ctx context.Context, entityName string, queryStr string, cfg
156
156
  } else {
157
157
  query = map[string]interface{}{
158
158
  "query": map[string]interface{}{
159
- "multi_match": map[string]interface{}{
160
- "query": queryStr,
161
- "fields": []string{"*"},
162
- "fuzziness": "AUTO",
159
+ "query_string": map[string]interface{}{
160
+ "query": queryStr,
161
+ "analyze_wildcard": true,
163
162
  },
164
163
  },
165
164
  }
@@ -79,7 +79,13 @@ DROP TABLE IF EXISTS api_usage CASCADE;
79
79
 
80
80
  const entitiesToCreate = delta ? delta.newEntities : entities;
81
81
 
82
- if (entitiesToCreate.length === 0 && (!delta || (!delta.newFields && !delta.newRelationships))) {
82
+ const hasNewFields = delta && delta.newFields && Object.keys(delta.newFields).length > 0;
83
+ const hasNewRelationships = delta && delta.newRelationships && delta.newRelationships.length > 0;
84
+ const hasDeletedEntities = delta && delta.deletedEntities && delta.deletedEntities.length > 0;
85
+ const hasDeletedFields = delta && delta.deletedFields && Object.keys(delta.deletedFields).length > 0;
86
+ const hasAlteredFields = delta && delta.alteredFields && Object.keys(delta.alteredFields).length > 0;
87
+
88
+ if (entitiesToCreate.length === 0 && !hasNewFields && !hasNewRelationships && !hasDeletedEntities && !hasDeletedFields && !hasAlteredFields) {
83
89
  return;
84
90
  }
85
91
 
@@ -125,6 +131,70 @@ DROP TABLE IF EXISTS api_usage CASCADE;
125
131
  }
126
132
  }
127
133
 
134
+ // Delete Entities
135
+ if (delta && delta.deletedEntities) {
136
+ for (const entity of delta.deletedEntities) {
137
+ sqlUp += `DROP TABLE IF EXISTS ${entity.name.toLowerCase()} CASCADE;\n\n`;
138
+ sqlDown += `-- Cannot easily reverse DROP TABLE ${entity.name.toLowerCase()}\n`;
139
+ }
140
+ }
141
+
142
+ // Delete Fields
143
+ if (delta && delta.deletedFields) {
144
+ for (const [entityName, fields] of Object.entries(delta.deletedFields)) {
145
+ for (const field of fields) {
146
+ sqlUp += `ALTER TABLE ${entityName.toLowerCase()} DROP COLUMN IF EXISTS ${toSnakeCase(field.name)};\n`;
147
+
148
+ let sqlType = toLiquibaseType(field, enums);
149
+ if (sqlType === 'JSON') sqlType = 'JSONB';
150
+ sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ADD COLUMN IF NOT EXISTS ${toSnakeCase(field.name)} ${sqlType};\n`;
151
+ }
152
+ }
153
+ }
154
+
155
+ // Alter Fields
156
+ if (delta && delta.alteredFields) {
157
+ for (const [entityName, alterations] of Object.entries(delta.alteredFields)) {
158
+ for (const alt of alterations) {
159
+ const fieldName = toSnakeCase(alt.new.name);
160
+
161
+ // Type changes
162
+ if (alt.new.type !== alt.old.type) {
163
+ let newSqlType = toLiquibaseType(alt.new, enums);
164
+ if (newSqlType === 'JSON') newSqlType = 'JSONB';
165
+ let oldSqlType = toLiquibaseType(alt.old, enums);
166
+ if (oldSqlType === 'JSON') oldSqlType = 'JSONB';
167
+
168
+ sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} TYPE ${newSqlType} USING ${fieldName}::${newSqlType};\n`;
169
+ sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} TYPE ${oldSqlType} USING ${fieldName}::${oldSqlType};\n`;
170
+ }
171
+
172
+ // Required (NOT NULL) changes
173
+ if (!!alt.new.required !== !!alt.old.required) {
174
+ if (alt.new.required) {
175
+ sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} SET NOT NULL;\n`;
176
+ sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} DROP NOT NULL;\n`;
177
+ } else {
178
+ sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} DROP NOT NULL;\n`;
179
+ sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ALTER COLUMN ${fieldName} SET NOT NULL;\n`;
180
+ }
181
+ }
182
+
183
+ // Unique changes
184
+ if (!!alt.new.unique !== !!alt.old.unique) {
185
+ const constraintName = `${entityName.toLowerCase()}_${fieldName}_key`;
186
+ if (alt.new.unique) {
187
+ sqlUp += `ALTER TABLE ${entityName.toLowerCase()} ADD CONSTRAINT ${constraintName} UNIQUE (${fieldName});\n`;
188
+ sqlDown += `ALTER TABLE ${entityName.toLowerCase()} DROP CONSTRAINT IF EXISTS ${constraintName};\n`;
189
+ } else {
190
+ sqlUp += `ALTER TABLE ${entityName.toLowerCase()} DROP CONSTRAINT IF EXISTS ${constraintName};\n`;
191
+ sqlDown += `ALTER TABLE ${entityName.toLowerCase()} ADD CONSTRAINT ${constraintName} UNIQUE (${fieldName});\n`;
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
128
198
  sqlUp += '-- +goose StatementEnd\n\n';
129
199
  sqlDown += '-- +goose StatementEnd\n';
130
200
 
@@ -133,7 +203,10 @@ DROP TABLE IF EXISTS api_usage CASCADE;
133
203
  descParts.push('initial_schema');
134
204
  } else {
135
205
  if (delta.newEntities?.length > 0) descParts.push('create_' + delta.newEntities.map(e => e.name.toLowerCase()).join('_'));
136
- if (delta.newFields) descParts.push('add_fields');
206
+ if (hasNewFields) descParts.push('add_fields');
207
+ if (hasDeletedEntities) descParts.push('drop_tables');
208
+ if (hasDeletedFields) descParts.push('drop_fields');
209
+ if (hasAlteredFields) descParts.push('alter_fields');
137
210
  }
138
211
 
139
212
  const fileName = `${timestamp}_${descParts.join('_')}.sql`;
@@ -42,6 +42,7 @@ import (
42
42
  "{{app_name}}/models"
43
43
  "{{app_name}}/config"
44
44
  "{{app_name}}/middleware"
45
+ "gorm.io/gorm"
45
46
  "gorm.io/gorm/clause"
46
47
  "gorm.io/gorm/schema"
47
48
  )
@@ -169,11 +169,25 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
169
169
  });
170
170
 
171
171
  // 3. GraphQL
172
- collection.item.push({
172
+ const generateGqlFieldsInput = (fields) => {
173
+ return fields.map(f => {
174
+ if (f.type === 'String' || f.type === 'Text') return `${f.name}: "Sample ${f.name}"`;
175
+ if (f.type === 'Integer' || f.type === 'Long') return `${f.name}: 100`;
176
+ if (f.type === 'Float' || f.type === 'BigDecimal') return `${f.name}: 99.99`;
177
+ if (f.type === 'Boolean') return `${f.name}: true`;
178
+ if (f.type === 'LocalDate') return `${f.name}: "2024-01-01"`;
179
+ if (f.type === 'Instant') return `${f.name}: "2024-01-01T12:00:00Z"`;
180
+ if (f.type === 'JSON' || f.type === 'JSONB') return `${f.name}: "{\\"attribute\\": \\"example_value\\"}"`;
181
+ return `${f.name}: "test"`;
182
+ }).join(',\n ');
183
+ };
184
+
185
+ const graphqlFolder = {
173
186
  name: "3. GraphQL Federation Layer",
187
+ description: "Federated GraphQL operations for all GDL entities.",
174
188
  item: [
175
189
  {
176
- name: "1. Unified GraphQL Engine",
190
+ name: "GraphQL Endpoint Playground",
177
191
  request: {
178
192
  method: "POST",
179
193
  header: [
@@ -184,7 +198,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
184
198
  body: {
185
199
  mode: "graphql",
186
200
  graphql: {
187
- query: `query {\n ${entities.length > 0 ? 'list' + entities[0].name.charAt(0).toUpperCase() + entities[0].name.slice(1) + 's' : 'test'} {\n id\n }\n}`,
201
+ query: "query {\n __schema {\n types {\n name\n }\n }\n}",
188
202
  variables: ""
189
203
  }
190
204
  },
@@ -198,7 +212,143 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
198
212
  }
199
213
  }
200
214
  ]
201
- });
215
+ };
216
+
217
+ for (const entity of entities) {
218
+ const capitalized = entity.name.charAt(0).toUpperCase() + entity.name.slice(1);
219
+ const entityGqlFolder = {
220
+ name: `${capitalized} GraphQL`,
221
+ item: [
222
+ {
223
+ name: `List ${capitalized}s (GraphQL Query)`,
224
+ request: {
225
+ method: "POST",
226
+ header: [
227
+ { key: "Authorization", value: "Bearer {{token}}" },
228
+ { key: "X-Tenant-ID", value: "{{tenant}}" },
229
+ { key: "Content-Type", value: "application/json" }
230
+ ],
231
+ body: {
232
+ mode: "graphql",
233
+ graphql: {
234
+ query: `query {\n list${capitalized}s(page: 1, size: 10) {\n role\n items {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
235
+ variables: ""
236
+ }
237
+ },
238
+ url: {
239
+ raw: "http://{{host}}:{{port}}/graphql",
240
+ protocol: "http",
241
+ host: ["{{host}}"],
242
+ port: "{{port}}",
243
+ path: ["graphql"]
244
+ }
245
+ }
246
+ },
247
+ {
248
+ name: `Get ${capitalized} by ID (GraphQL Query)`,
249
+ request: {
250
+ method: "POST",
251
+ header: [
252
+ { key: "Authorization", value: "Bearer {{token}}" },
253
+ { key: "X-Tenant-ID", value: "{{tenant}}" },
254
+ { key: "Content-Type", value: "application/json" }
255
+ ],
256
+ body: {
257
+ mode: "graphql",
258
+ graphql: {
259
+ query: `query {\n get${capitalized}(id: "1") {\n role\n data {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
260
+ variables: ""
261
+ }
262
+ },
263
+ url: {
264
+ raw: "http://{{host}}:{{port}}/graphql",
265
+ protocol: "http",
266
+ host: ["{{host}}"],
267
+ port: "{{port}}",
268
+ path: ["graphql"]
269
+ }
270
+ }
271
+ },
272
+ {
273
+ name: `Create ${capitalized} (GraphQL Mutation)`,
274
+ request: {
275
+ method: "POST",
276
+ header: [
277
+ { key: "Authorization", value: "Bearer {{token}}" },
278
+ { key: "X-Tenant-ID", value: "{{tenant}}" },
279
+ { key: "Content-Type", value: "application/json" }
280
+ ],
281
+ body: {
282
+ mode: "graphql",
283
+ graphql: {
284
+ query: `mutation {\n create${capitalized}(input: {\n ${generateGqlFieldsInput(entity.fields)}\n }) {\n role\n data {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
285
+ variables: ""
286
+ }
287
+ },
288
+ url: {
289
+ raw: "http://{{host}}:{{port}}/graphql",
290
+ protocol: "http",
291
+ host: ["{{host}}"],
292
+ port: "{{port}}",
293
+ path: ["graphql"]
294
+ }
295
+ }
296
+ },
297
+ {
298
+ name: `Update ${capitalized} (GraphQL Mutation)`,
299
+ request: {
300
+ method: "POST",
301
+ header: [
302
+ { key: "Authorization", value: "Bearer {{token}}" },
303
+ { key: "X-Tenant-ID", value: "{{tenant}}" },
304
+ { key: "Content-Type", value: "application/json" }
305
+ ],
306
+ body: {
307
+ mode: "graphql",
308
+ graphql: {
309
+ query: `mutation {\n update${capitalized}(id: "1", input: {\n ${generateGqlFieldsInput(entity.fields)}\n }) {\n role\n data {\n id\n ${entity.fields.map(f => f.name).join('\n ')}\n createdAt\n updatedAt\n }\n }\n}`,
310
+ variables: ""
311
+ }
312
+ },
313
+ url: {
314
+ raw: "http://{{host}}:{{port}}/graphql",
315
+ protocol: "http",
316
+ host: ["{{host}}"],
317
+ port: "{{port}}",
318
+ path: ["graphql"]
319
+ }
320
+ }
321
+ },
322
+ {
323
+ name: `Delete ${capitalized} (GraphQL Mutation)`,
324
+ request: {
325
+ method: "POST",
326
+ header: [
327
+ { key: "Authorization", value: "Bearer {{token}}" },
328
+ { key: "X-Tenant-ID", value: "{{tenant}}" },
329
+ { key: "Content-Type", value: "application/json" }
330
+ ],
331
+ body: {
332
+ mode: "graphql",
333
+ graphql: {
334
+ query: `mutation {\n delete${capitalized}(id: "1")\n}`,
335
+ variables: ""
336
+ }
337
+ },
338
+ url: {
339
+ raw: "http://{{host}}:{{port}}/graphql",
340
+ protocol: "http",
341
+ host: ["{{host}}"],
342
+ port: "{{port}}",
343
+ path: ["graphql"]
344
+ }
345
+ }
346
+ }
347
+ ]
348
+ };
349
+ graphqlFolder.item.push(entityGqlFolder);
350
+ }
351
+ collection.item.push(graphqlFolder);
202
352
 
203
353
  // 4. REST Entities
204
354
  const restFolder = { name: "4. Standard REST & Deep Search", item: [] };
@@ -240,7 +390,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
240
390
  request: {
241
391
  method: "GET",
242
392
  header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
243
- url: { raw: `http://{{host}}:{{port}}/open/api/${name}s?page=1&size=10&eager=true`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", "api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" } ] }
393
+ url: { raw: `http://{{host}}:{{port}}/open/api/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", "api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
244
394
  }
245
395
  });
246
396
  publicItems.push({
@@ -275,7 +425,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
275
425
  request: {
276
426
  method: "GET",
277
427
  header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
278
- url: { raw: `http://{{host}}:{{port}}/api/${name}s?page=1&size=10&eager=true`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" } ] }
428
+ url: { raw: `http://{{host}}:{{port}}/api/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
279
429
  }
280
430
  },
281
431
  {
package/index.js CHANGED
@@ -357,7 +357,7 @@ const getPreviousEntities = async (outputDir) => {
357
357
  return entities;
358
358
  };
359
359
 
360
- const generateEntities = async (gdlPath, outputDir, config) => {
360
+ const generateEntities = async (gdlPath, outputDir, config, isImport = false) => {
361
361
  let entities = [];
362
362
  let relationships = [];
363
363
  let enums = [];
@@ -365,7 +365,7 @@ const generateEntities = async (gdlPath, outputDir, config) => {
365
365
 
366
366
  if (!await fs.pathExists(gdlPath)) {
367
367
  console.log(chalk.yellow(`⚠️ GDL path not found: ${gdlPath} - Executing Zero-Entity Infrastructure Scaffold.`));
368
- const delta = { newEntities: [], newFields: {}, newRelationships: [] };
368
+ const delta = { newEntities: [], newFields: {}, newRelationships: [], deletedEntities: [], deletedFields: {}, alteredFields: {} };
369
369
  await generateLiquibaseChangelogs(entities, relationships, outputDir, delta, enums);
370
370
  return { entities, relationships, enums, openEntities };
371
371
  }
@@ -377,7 +377,7 @@ const generateEntities = async (gdlPath, outputDir, config) => {
377
377
 
378
378
  if (files.length === 0) {
379
379
  console.log(chalk.yellow(`⚠️ No .gdl files found in: ${gdlPath} - Executing Zero-Entity Infrastructure Scaffold.`));
380
- const delta = { newEntities: [], newFields: {}, newRelationships: [] };
380
+ const delta = { newEntities: [], newFields: {}, newRelationships: [], deletedEntities: [], deletedFields: {}, alteredFields: {} };
381
381
  await generateLiquibaseChangelogs(entities, relationships, outputDir, delta, enums);
382
382
  return { entities, relationships, enums, openEntities };
383
383
  }
@@ -399,10 +399,54 @@ const generateEntities = async (gdlPath, outputDir, config) => {
399
399
  console.log(chalk.green(`βœ… Parsed ${entities.length} entities, ${relationships.length} relationships, ${enums.length} enums, and detected ${openEntities.length} open rules`));
400
400
 
401
401
  const previousEntities = await getPreviousEntities(outputDir);
402
+
403
+ // Any parsed entity with @Delete is mapped as a deletion
404
+ const deletedParsed = entities.filter(e => e.isDelete);
405
+
406
+ // Remove deleted entities from active entities array
407
+ entities = entities.filter(e => !e.isDelete);
408
+
409
+ // Filter out relationships & open rule entries that involve deleted entities
410
+ relationships = relationships.filter(rel =>
411
+ !deletedParsed.some(d => d.name === rel.from.entity || d.name === rel.to.entity)
412
+ );
413
+ openEntities = openEntities.filter(oe =>
414
+ !deletedParsed.some(d => d.name === oe.name)
415
+ );
416
+
417
+ if (isImport) {
418
+ for (const prev of previousEntities) {
419
+ if (!entities.some(e => e.name === prev.name) && !deletedParsed.some(d => d.name === prev.name)) {
420
+ entities.push(prev);
421
+ if (prev.relationships) {
422
+ for (const r of prev.relationships) {
423
+ const exists = relationships.some(rel =>
424
+ (rel.from.entity === r.from.entity && rel.from.field === r.from.field && rel.to.entity === r.to.entity && rel.to.field === r.to.field) ||
425
+ (rel.from.entity === r.to.entity && rel.from.field === r.to.field && rel.to.entity === r.from.entity && rel.to.field === r.from.field)
426
+ );
427
+ if (!exists) relationships.push(r);
428
+ }
429
+ }
430
+ if (prev.enums) {
431
+ for (const en of prev.enums) {
432
+ if (!enums.some(e => e.name === en.name)) enums.push(en);
433
+ }
434
+ }
435
+ if (prev.openEntities) {
436
+ for (const oe of prev.openEntities) {
437
+ if (!openEntities.some(o => o.name === oe.name)) openEntities.push(oe);
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
402
443
  const delta = {
403
444
  newEntities: [],
404
445
  newFields: {},
405
- newRelationships: []
446
+ newRelationships: [],
447
+ deletedEntities: [],
448
+ deletedFields: {},
449
+ alteredFields: {}
406
450
  };
407
451
 
408
452
  // Calculate Delta for Incremental Migrations
@@ -416,6 +460,42 @@ const generateEntities = async (gdlPath, outputDir, config) => {
416
460
  if (newFields.length > 0) {
417
461
  delta.newFields[entity.name] = newFields;
418
462
  }
463
+
464
+ // Check for deleted fields
465
+ const deletedFields = prev.fields.filter(pf => !entity.fields.some(f => f.name === pf.name));
466
+ if (deletedFields.length > 0) {
467
+ delta.deletedFields[entity.name] = deletedFields;
468
+ }
469
+
470
+ // Check for altered fields
471
+ const alteredFields = [];
472
+ for (const f of entity.fields) {
473
+ const pf = prev.fields.find(p => p.name === f.name);
474
+ if (pf) {
475
+ if (pf.type !== f.type || !!pf.required !== !!f.required || !!pf.unique !== !!f.unique) {
476
+ alteredFields.push({ new: f, old: pf });
477
+ }
478
+ }
479
+ }
480
+ if (alteredFields.length > 0) {
481
+ delta.alteredFields[entity.name] = alteredFields;
482
+ }
483
+ }
484
+ }
485
+
486
+ // Check for deleted entities
487
+ if (isImport) {
488
+ for (const d of deletedParsed) {
489
+ const prev = previousEntities.find(e => e.name === d.name);
490
+ if (prev) {
491
+ delta.deletedEntities.push(prev);
492
+ }
493
+ }
494
+ } else {
495
+ for (const prev of previousEntities) {
496
+ if (!entities.some(e => e.name === prev.name)) {
497
+ delta.deletedEntities.push(prev);
498
+ }
419
499
  }
420
500
  }
421
501
 
@@ -481,6 +561,21 @@ const generateEntities = async (gdlPath, outputDir, config) => {
481
561
  await saveEntitySnapshot(outputDir, entity);
482
562
  }
483
563
 
564
+ // Clean up files for deleted entities
565
+ if (delta && delta.deletedEntities) {
566
+ for (const deleted of delta.deletedEntities) {
567
+ const modelFile = path.join(outputDir, 'models', `${deleted.name.toLowerCase()}.go`);
568
+ const controllerFile = path.join(outputDir, 'controllers', `${deleted.name.toLowerCase()}_controller.go`);
569
+ const snapshotFile = path.join(outputDir, '.go-duck', `${deleted.name.toLowerCase()}.json`);
570
+
571
+ if (await fs.pathExists(modelFile)) await fs.remove(modelFile);
572
+ if (await fs.pathExists(controllerFile)) await fs.remove(controllerFile);
573
+ if (await fs.pathExists(snapshotFile)) await fs.remove(snapshotFile);
574
+
575
+ console.log(chalk.red(` - Removed Entity & Controller: ${deleted.name}`));
576
+ }
577
+ }
578
+
484
579
  // Generate Incremental Changelogs!
485
580
  await generateLiquibaseChangelogs(entities, relationships, outputDir, delta, enums);
486
581
  console.log(chalk.green('βœ… Goose SQL incremental migrations updated!'));
@@ -717,7 +812,7 @@ require (
717
812
  await generateResilienceCode(config, absoluteOutputDir);
718
813
  await generateTelemetryCode(config, absoluteOutputDir);
719
814
  await generateDeploymentArtifacts(config, absoluteOutputDir);
720
- const { entities, relationships, enums, openEntities } = await generateEntities(gdlPath, absoluteOutputDir, config);
815
+ const { entities, relationships, enums, openEntities } = await generateEntities(gdlPath, absoluteOutputDir, config, true);
721
816
  await generateKratosCode(entities, absoluteOutputDir, config.name, enums);
722
817
 
723
818
  await generateRepositoryCode(absoluteOutputDir);
@@ -783,13 +878,18 @@ const generateYAMLConfigs = async (config, outputDir) => {
783
878
  const extendedConfig = {
784
879
  ...cleanConfig,
785
880
  server: {
786
- port: cleanConfig.server?.port || 8080,
881
+ rest: {
882
+ port: cleanConfig.server?.rest?.port || cleanConfig.server?.port || 8080,
883
+ protocol: cleanConfig.server?.rest?.protocol || 'json'
884
+ },
787
885
  'read-timeout': cleanConfig.server?.['read-timeout'] || '30s',
788
886
  'write-timeout': cleanConfig.server?.['write-timeout'] || '30s',
789
887
  grpc: {
790
888
  addr: cleanConfig.server?.grpc?.addr || ':9000',
791
889
  network: cleanConfig.server?.grpc?.network || 'tcp',
792
- timeout: cleanConfig.server?.grpc?.timeout || '1s'
890
+ timeout: cleanConfig.server?.grpc?.timeout || '1s',
891
+ web_enabled: cleanConfig.server?.grpc?.web_enabled ?? true,
892
+ web_port: cleanConfig.server?.grpc?.web_port || 9090
793
893
  },
794
894
  cors: {
795
895
  'allow-origins': cleanConfig.server?.cors?.['allow-origins'] || ['*'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-duck-cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "The Ultimate Evolutionary Go Microservice Scaffolder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/parser/gdl.js CHANGED
@@ -179,6 +179,7 @@ export const parseGDL = async (filePath) => {
179
179
  const isSearchable = annotation?.includes('@Searchable');
180
180
  const isDocument = annotation?.includes('@Document') || annotation?.includes('@isDocument');
181
181
  const isEmbedded = annotation?.includes('@Embed');
182
+ const isDelete = annotation?.includes('@Delete');
182
183
 
183
184
  const fields = parseFields(block.fieldBlock);
184
185
 
@@ -190,6 +191,7 @@ export const parseGDL = async (filePath) => {
190
191
  isSearchable,
191
192
  isDocument,
192
193
  isEmbedded,
194
+ isDelete,
193
195
  fields
194
196
  });
195
197
  }
@@ -69,7 +69,8 @@ go run main.go</code></pre>
69
69
  <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">1. REST APIs & Generic Search</h2>
70
70
  <p class="mb-4">The application provides standard RESTful CRUD endpoints for all generated entities (e.g., {{#if entities.length}}{{entities.[0].name}}{{else}}Entity{{/if}}).</p>
71
71
 
72
- <h3 class="font-semibold mb-2">Standard CRUD:</h3>
72
+ <h3 class="font-semibold mb-2">Standard CRUD (Multi-Protocol):</h3>
73
+ <p class="mb-2 text-sm text-gray-600">The REST API natively supports JSON and MessagePack based on your <code>application.yml</code> settings.</p>
73
74
  <pre><code class="language-http">GET /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}entities{{/if}}?page=1&pageSize=10
74
75
  POST /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}entities{{/if}}
75
76
  PUT /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}entities{{/if}}/:id
@@ -81,6 +82,17 @@ DELETE /api/{{#if entities.length}}{{toLowerCase entities.[0].name}}s{{else}}ent
81
82
  -H "Authorization: Bearer YOUR_JWT" \
82
83
  -H "X-Tenant-ID: tenant_1"</code></pre>
83
84
  <p class="text-sm text-gray-500 mt-2">Supported operators: <code>eq, neq, gt, gte, lt, lte, like, ilike</code></p>
85
+
86
+ <h3 class="font-semibold mb-2 mt-6">Elasticsearch Global Search (Spring-style):</h3>
87
+ <p class="mb-2">For entities marked with <code>@Searchable</code>, use the native Elasticsearch endpoint for advanced queries (wildcards, booleans, and ranges).</p>
88
+ <pre><code class="language-bash">curl -G "http://localhost:{{serverPort}}/api/search/{{#if entities.length}}{{toLowerCase entities.[0].name}}{{else}}entity{{/if}}" \
89
+ --data-urlencode "q=name:John AND age:>18" \
90
+ -H "Authorization: Bearer YOUR_JWT"</code></pre>
91
+ <ul class="text-sm text-gray-500 mt-2 list-disc pl-5">
92
+ <li><strong>Wildcards:</strong> <code>q=name*</code> (matches "name15", "name_abc")</li>
93
+ <li><strong>Boolean Logic:</strong> <code>q=status:PUBLISHED AND (author:John OR author:Jane)</code></li>
94
+ <li><strong>Ranges:</strong> <code>q=age:[18 TO 30]</code> or <code>q=created_at:>2023-01-01</code></li>
95
+ </ul>
84
96
  </section>
85
97
 
86
98
  <!-- Audit & Metering -->
@@ -127,6 +139,17 @@ mosquitto_sub -h localhost -p {{mqttPort}} -t "go-duck/events/#" -u dev_user -P
127
139
  <p class="text-sm mt-2">Example topic: <code>go-duck/events/{{#if entities.length}}{{toLowerCase entities.[0].name}}{{else}}entity{{/if}}/CREATE</code></p>
128
140
  </section>
129
141
 
142
+ <!-- gRPC-Web -->
143
+ <section id="grpc-web" class="content-section">
144
+ <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">gRPC & gRPC-Web</h2>
145
+ <p class="mb-4">The Kratos gRPC engine powers blazing fast service-to-service communication. For frontend developers, we automatically start a <strong>gRPC-Web Proxy</strong>.</p>
146
+ <ul class="list-disc pl-6 space-y-2 mb-4">
147
+ <li>The core Kratos gRPC server runs on <code>:9000</code> (configurable).</li>
148
+ <li>The <strong>gRPC-Web Proxy</strong> runs on <code>:9090</code>, translating HTTP/1.1 calls to HTTP/2.</li>
149
+ <li>Frontend apps (React/Angular) can directly call Protobuf endpoints using standard <code>grpc-web</code> generated clients without hitting the REST APIs!</li>
150
+ </ul>
151
+ </section>
152
+
130
153
  <!-- Kratos gRPC -->
131
154
  <section id="grpc-kratos" class="content-section">
132
155
  <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">Kratos Secured gRPC APIs</h2>
@@ -212,7 +235,8 @@ res, err := client.Get{{#if entities.length}}{{capitalize entities.[0].name}}{{e
212
235
  <p class="mb-4">Running <code>go-duck import-gdl</code> calculates stateful differences using the `.go-duck/` snapshot folder. It generates atomic, Go-embedded timestamped Goose SQL migrations in <code>migrations/sql/</code>.</p>
213
236
  <ul class="list-disc pl-6 space-y-2">
214
237
  <li>The application natively compiles SQL inside the Go binary via <code>go:embed</code>.</li>
215
- <li>Includes smart nullability and automatic indexing for Foreign Keys.</li>
238
+ <li><strong>Full-Spectrum Schema Evolution:</strong> The CLI detects complex deltas, generating SQL for dropped entities (`DROP TABLE`), dropped fields (`DROP COLUMN`), and altered fields (`ALTER TYPE`, constraints).</li>
239
+ <li><strong>Dead-Code Purging:</strong> The CLI natively tracks and deletes orphaned `.go` models, controllers, and state snapshots for entities you remove from the `.gdl` blueprint.</li>
216
240
  <li><strong>Needle Support:</strong> We inject `// go-duck-needle-*` markers in <code>main.go</code> and <code>grpc.go</code> for safe evolutionary code additions without destroying manual updates.</li>
217
241
  </ul>
218
242
  </section>
@@ -44,17 +44,33 @@ go-duck import-gdl my_new_schema.gdl -o ./MY_APP</code></pre>
44
44
  <span class="w-8 h-8 rounded-lg bg-emerald-100 text-emerald-600 flex items-center justify-center mr-3 text-sm">
45
45
  <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"></path></svg>
46
46
  </span>
47
- 2. Persistence Intelligence & the `.go-duck/` Folder
47
+ 2. Schema Evolution & the `.go-duck/` State
48
48
  </h2>
49
- <p class="mb-4 text-slate-600 leading-relaxed font-medium">"If I run the generator twice, will it overwrite my database migrations?" <strong>No.</strong></p>
49
+ <p class="mb-4 text-slate-600 leading-relaxed font-medium">"If I run the generator to import a new GDL, will it overwrite my database or delete existing entities?" <strong>No, thanks to stateful evolution.</strong></p>
50
50
 
51
- <p class="mb-6 text-slate-600 leading-relaxed">The generator maintains a stateful snapshot of every entity it has ever generated inside the hidden <code class="bg-slate-100 px-1 py-0.5 rounded text-slate-800 font-mono text-sm">.go-duck/</code> directory at the root of your target project. When you run <code>import-gdl</code>, the code parser literally diffs your new file against the JSON representations in <code>.go-duck/</code> to intelligently assess the exact Table changes necessary without executing ghost migrations.</p>
51
+ <p class="mb-6 text-slate-600 leading-relaxed">The generator maintains a stateful snapshot of every entity it has ever generated inside the hidden <code class="bg-slate-100 px-1 py-0.5 rounded text-slate-800 font-mono text-sm">.go-duck/</code> directory at the root of your target project. When you run <code>import-gdl</code>, the CLI intelligently merges existing entities with newly parsed entities and executes targeted diff operations:</p>
52
+
53
+ <!-- EVOLUTION TYPE CARDS -->
54
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
55
+ <div class="p-6 bg-slate-50 border border-slate-200 rounded-2xl">
56
+ <span class="text-xs font-bold text-indigo-600 uppercase tracking-wide block mb-2 font-mono">1. Snapshot Merging</span>
57
+ <p class="text-xs text-slate-600 leading-relaxed m-0">Supports splitting your entities into multiple GDL files. Unspecified active models are loaded from the <code>.go-duck/</code> folder and merged with new entities, keeping active routers and endpoints in sync.</p>
58
+ </div>
59
+ <div class="p-6 bg-slate-50 border border-slate-200 rounded-2xl">
60
+ <span class="text-xs font-bold text-emerald-600 uppercase tracking-wide block mb-2 font-mono">2. Column Alterations</span>
61
+ <p class="text-xs text-slate-600 leading-relaxed m-0">Adding, dropping, or modifying fields inside entity blocks generates specific <code>ADD COLUMN</code> or <code>DROP COLUMN</code> SQL statements in a timestamped Goose migration file.</p>
62
+ </div>
63
+ <div class="p-6 bg-slate-50 border border-slate-200 rounded-2xl">
64
+ <span class="text-xs font-bold text-rose-600 uppercase tracking-wide block mb-2 font-mono">3. Complete Purging</span>
65
+ <p class="text-xs text-slate-600 leading-relaxed m-0">Marking an entity with the <code>@Delete</code> annotation automatically triggers a database <code>DROP TABLE</code> SQL migration, purges all generated Go/Protobuf code files, and clears its snapshot.</p>
66
+ </div>
67
+ </div>
52
68
 
53
69
  <div class="bg-rose-50 border border-rose-200 p-5 mb-6 rounded-xl flex items-start shadow-sm shadow-rose-100/50">
54
70
  <svg class="w-6 h-6 text-rose-500 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
55
71
  <div>
56
72
  <h4 class="font-bold text-rose-900 mb-1">Warning</h4>
57
- <p class="text-rose-800 text-sm leading-relaxed"><strong>Never delete `.go-duck/`</strong> unless you are intentionally wiping the database and starting configuration completely from zero.</p>
73
+ <p class="text-rose-800 text-sm leading-relaxed"><strong>Never manually delete `.go-duck/`</strong> unless you are intentionally wiping the entire database state and starting configuration completely from scratch.</p>
58
74
  </div>
59
75
  </div>
60
76
  </section>
@@ -100,6 +100,23 @@
100
100
  <p class="text-[11px] text-indigo-900 m-0 font-medium font-mono leading-tight">Architectural Impact: Scaffolds routes into the <code class="text-indigo-600 text-[10px]">/api/open/*</code> group, bypassing the OIDC Validator middleware chain.</p>
101
101
  </div>
102
102
  </div>
103
+
104
+ <!-- @Delete -->
105
+ <div class="p-10 bg-rose-50 border border-rose-100 rounded-[3rem] shadow-sm transition-all duration-300 hover:shadow-rose-200 group relative overflow-hidden">
106
+ <div class="absolute top-0 right-0 p-6 opacity-[0.03] group-hover:opacity-[0.07] transition-opacity">
107
+ <svg class="w-32 h-32 text-rose-900" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
108
+ </div>
109
+ <div class="flex items-center justify-between mb-8">
110
+ <code class="text-rose-700 font-mono font-black text-2xl group-hover:scale-105 transition-transform">@Delete</code>
111
+ <div class="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-2xl shadow-sm">πŸ—‘οΈ</div>
112
+ </div>
113
+ <h4 class="text-lg font-black text-slate-900 mb-4 m-0 uppercase tracking-tighter">Entity Deletion</h4>
114
+ <p class="text-sm text-slate-700 leading-relaxed m-0 italic mb-6">Triggers full entity cleanup. Upon import, the generator purges all generated source code (models, controllers, repositories) and generates a database <code>DROP TABLE</code> migration.</p>
115
+ <div class="p-4 bg-white/50 rounded-2xl border border-rose-200 border-dashed">
116
+ <span class="text-[10px] font-bold text-rose-400 uppercase tracking-widest block mb-2 font-mono">Architectural Impact</span>
117
+ <p class="text-[11px] text-rose-900 m-0 font-medium font-mono leading-tight">Removes snapshots from .go-duck/, wipes Go files, and scaffolds drop SQL statements.</p>
118
+ </div>
119
+ </div>
103
120
  </div>
104
121
 
105
122
  <!-- COMPLEX EXAMPLE -->
@@ -129,6 +129,27 @@
129
129
  </div>
130
130
  </section>
131
131
 
132
+ <!-- PAGINATION & SORTING -->
133
+ <section class="mb-20">
134
+ <h2 class="text-3xl font-black text-slate-900 mb-8 tracking-tight italic underline decoration-indigo-600 underline-offset-8">Pagination & Dynamic Sorting</h2>
135
+ <div class="p-8 bg-slate-50 border border-slate-200 rounded-[2.5rem] shadow-sm">
136
+ <p class="text-slate-600 mb-6">List endpoints support pagination and dynamic sorting for both relational (PostgreSQL) and document-based (MongoDB) silos.</p>
137
+
138
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
139
+ <div class="p-6 bg-white rounded-2xl border border-slate-100 shadow-sm border-l-4 border-l-indigo-600">
140
+ <h4 class="font-bold text-slate-900 mb-2">Pagination</h4>
141
+ <p class="text-xs text-slate-500 mb-4">Specify the page number (1-indexed) and limit size via standard query parameters. The response contains total count in the <code>X-Total-Count</code> header.</p>
142
+ <code class="text-xs font-mono font-bold text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full">GET /api/v1/car?page=1&amp;size=20</code>
143
+ </div>
144
+ <div class="p-6 bg-white rounded-2xl border border-slate-100 shadow-sm border-l-4 border-l-emerald-600">
145
+ <h4 class="font-bold text-slate-900 mb-2">Dynamic Sorting</h4>
146
+ <p class="text-xs text-slate-500 mb-4">Sort fields dynamically in ascending (default or <code>asc</code>) or descending (<code>desc</code>) order. The sorting format is <code>?sort=fieldname,direction</code>.</p>
147
+ <code class="text-xs font-mono font-bold text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full">GET /api/v1/car?sort=price,desc</code>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </section>
152
+
132
153
  <!-- RPC OPERATORS -->
133
154
  <section class="mb-10 text-center">
134
155
  <h2 class="text-2xl font-black text-slate-900 mb-6 italic italic underline decoration-indigo-200 underline-offset-8 decoration-8 font-serif uppercase tracking-widest">GORM Filter Reference</h2>
@@ -3,6 +3,7 @@ package controllers
3
3
  import (
4
4
  "net/http"
5
5
  "strconv"
6
+ "strings"
6
7
  {{#if isSearchable}}
7
8
  "context"
8
9
  {{/if}}
@@ -22,6 +23,8 @@ import (
22
23
  "{{app_name}}/internal/search"
23
24
  {{/if}}
24
25
  "github.com/gin-gonic/gin"
26
+ "github.com/gin-gonic/gin/binding"
27
+ "github.com/gin-gonic/gin/render"
25
28
  {{#if isDocument}}
26
29
  "go.mongodb.org/mongo-driver/bson"
27
30
  "go.mongodb.org/mongo-driver/mongo"
@@ -44,9 +47,16 @@ type {{capitalize name}}Controller struct {
44
47
  func (ctrl *{{capitalize name}}Controller) Create(c *gin.Context) {
45
48
  ctx := c.Request.Context()
46
49
  var entity models.{{capitalize name}}
47
- if err := c.ShouldBindJSON(&entity); err != nil {
48
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
49
- return
50
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
51
+ if err := c.ShouldBindWith(&entity, binding.MsgPack); err != nil {
52
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
53
+ return
54
+ }
55
+ } else {
56
+ if err := c.ShouldBindJSON(&entity); err != nil {
57
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
58
+ return
59
+ }
50
60
  }
51
61
 
52
62
  {{#if isDocument}}
@@ -138,7 +148,11 @@ func (ctrl *{{capitalize name}}Controller) Create(c *gin.Context) {
138
148
  }
139
149
  {{/if}}
140
150
 
141
- c.JSON(http.StatusCreated, entity)
151
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
152
+ c.Render(http.StatusCreated, render.MsgPack{Data: entity})
153
+ } else {
154
+ c.JSON(http.StatusCreated, entity)
155
+ }
142
156
  }
143
157
 
144
158
  // GetAll handles parallel read aggregation for both SQL and Mongo
@@ -146,6 +160,7 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
146
160
  ctx := c.Request.Context()
147
161
  page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
148
162
  size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
163
+ sortParam := c.DefaultQuery("sort", "")
149
164
 
150
165
  offset := 0
151
166
  if page > 0 {
@@ -169,6 +184,15 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
169
184
  defer wg.Done()
170
185
  var entities []models.{{capitalize name}}
171
186
  opts := options.Find().SetSkip(int64(offset)).SetLimit(int64(size))
187
+ if sortParam != "" {
188
+ parts := strings.Split(sortParam, ",")
189
+ field := parts[0]
190
+ order := 1
191
+ if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
192
+ order = -1
193
+ }
194
+ opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
195
+ }
172
196
  cursor, err := db.Collection("{{toLowerCase name}}s").Find(ctx, bson.M{}, opts)
173
197
  if err == nil {
174
198
  cursor.All(ctx, &entities)
@@ -179,7 +203,11 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
179
203
  }(role, siloDB)
180
204
  }
181
205
  wg.Wait()
182
- c.JSON(http.StatusOK, federatedData)
206
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
207
+ c.Render(http.StatusOK, render.MsgPack{Data: federatedData})
208
+ } else {
209
+ c.JSON(http.StatusOK, federatedData)
210
+ }
183
211
  return
184
212
  }
185
213
  {{/if}}
@@ -200,13 +228,26 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
200
228
 
201
229
  var entities []models.{{capitalize name}}
202
230
  opts := options.Find().SetSkip(int64(offset)).SetLimit(int64(size))
231
+ if sortParam != "" {
232
+ parts := strings.Split(sortParam, ",")
233
+ field := parts[0]
234
+ order := 1
235
+ if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
236
+ order = -1
237
+ }
238
+ opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
239
+ }
203
240
  cursor, err := tenantDB.Collection("{{toLowerCase name}}s").Find(ctx, filter, opts)
204
241
  if err != nil {
205
242
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
206
243
  return
207
244
  }
208
245
  cursor.All(ctx, &entities)
209
- c.JSON(http.StatusOK, entities)
246
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
247
+ c.Render(http.StatusOK, render.MsgPack{Data: entities})
248
+ } else {
249
+ c.JSON(http.StatusOK, entities)
250
+ }
210
251
 
211
252
  {{else}}
212
253
  // πŸ¦† GORM Path
@@ -227,7 +268,11 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
227
268
  go func(r string, db *gorm.DB) {
228
269
  defer wg.Done()
229
270
  var entities []models.{{capitalize name}}
230
- if err := db.WithContext(ctx).Offset(offset).Limit(size).Find(&entities).Error; err == nil {
271
+ query := db.WithContext(ctx)
272
+ if sortParam != "" {
273
+ query = query.Order(strings.ReplaceAll(sortParam, ",", " "))
274
+ }
275
+ if err := query.Offset(offset).Limit(size).Find(&entities).Error; err == nil {
231
276
  mu.Lock()
232
277
  federatedData[r] = entities
233
278
  mu.Unlock()
@@ -235,7 +280,11 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
235
280
  }(role, siloDB)
236
281
  }
237
282
  wg.Wait()
238
- c.JSON(http.StatusOK, federatedData)
283
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
284
+ c.Render(http.StatusOK, render.MsgPack{Data: federatedData})
285
+ } else {
286
+ c.JSON(http.StatusOK, federatedData)
287
+ }
239
288
  return
240
289
  }
241
290
  {{/if}}
@@ -248,11 +297,19 @@ func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
248
297
  c.Header("X-Total-Count", strconv.FormatInt(totalCount, 10))
249
298
 
250
299
  var entities []models.{{capitalize name}}
251
- if err := tenantDB.WithContext(ctx).Offset(offset).Limit(size).Find(&entities).Error; err != nil {
300
+ query := tenantDB.WithContext(ctx)
301
+ if sortParam != "" {
302
+ query = query.Order(strings.ReplaceAll(sortParam, ",", " "))
303
+ }
304
+ if err := query.Offset(offset).Limit(size).Find(&entities).Error; err != nil {
252
305
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
253
306
  return
254
307
  }
255
- c.JSON(http.StatusOK, entities)
308
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
309
+ c.Render(http.StatusOK, render.MsgPack{Data: entities})
310
+ } else {
311
+ c.JSON(http.StatusOK, entities)
312
+ }
256
313
  {{/if}}
257
314
  }
258
315
 
@@ -269,7 +326,11 @@ func (ctrl *{{capitalize name}}Controller) GetByID(c *gin.Context) {
269
326
  c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
270
327
  return
271
328
  }
272
- c.JSON(http.StatusOK, entity)
329
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
330
+ c.Render(http.StatusOK, render.MsgPack{Data: entity})
331
+ } else {
332
+ c.JSON(http.StatusOK, entity)
333
+ }
273
334
  {{else}}
274
335
  db, _ := c.Get("tenantDBConn")
275
336
  tenantDB := db.(*gorm.DB)
@@ -278,7 +339,11 @@ func (ctrl *{{capitalize name}}Controller) GetByID(c *gin.Context) {
278
339
  c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
279
340
  return
280
341
  }
281
- c.JSON(http.StatusOK, entity)
342
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
343
+ c.Render(http.StatusOK, render.MsgPack{Data: entity})
344
+ } else {
345
+ c.JSON(http.StatusOK, entity)
346
+ }
282
347
  {{/if}}
283
348
  }
284
349
 
@@ -286,9 +351,16 @@ func (ctrl *{{capitalize name}}Controller) Update(c *gin.Context) {
286
351
  ctx := c.Request.Context()
287
352
  id := c.Param("id")
288
353
  var entity models.{{capitalize name}}
289
- if err := c.ShouldBindJSON(&entity); err != nil {
290
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
291
- return
354
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
355
+ if err := c.ShouldBindWith(&entity, binding.MsgPack); err != nil {
356
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
357
+ return
358
+ }
359
+ } else {
360
+ if err := c.ShouldBindJSON(&entity); err != nil {
361
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
362
+ return
363
+ }
292
364
  }
293
365
 
294
366
  {{#if isDocument}}
@@ -320,7 +392,11 @@ func (ctrl *{{capitalize name}}Controller) Update(c *gin.Context) {
320
392
  messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "tenant", "UPDATE", "{{capitalize name}}", entity, nil)
321
393
  {{/if}}
322
394
 
323
- c.JSON(http.StatusOK, entity)
395
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
396
+ c.Render(http.StatusOK, render.MsgPack{Data: entity})
397
+ } else {
398
+ c.JSON(http.StatusOK, entity)
399
+ }
324
400
  }
325
401
 
326
402
  func (ctrl *{{capitalize name}}Controller) Delete(c *gin.Context) {
@@ -356,9 +432,16 @@ func (ctrl *{{capitalize name}}Controller) Patch(c *gin.Context) {
356
432
  ctx := c.Request.Context()
357
433
  id := c.Param("id")
358
434
  var updates map[string]interface{}
359
- if err := c.ShouldBindJSON(&updates); err != nil {
360
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
361
- return
435
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
436
+ if err := c.ShouldBindWith(&updates, binding.MsgPack); err != nil {
437
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
438
+ return
439
+ }
440
+ } else {
441
+ if err := c.ShouldBindJSON(&updates); err != nil {
442
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
443
+ return
444
+ }
362
445
  }
363
446
 
364
447
  {{#if isDocument}}
@@ -383,7 +466,11 @@ func (ctrl *{{capitalize name}}Controller) Patch(c *gin.Context) {
383
466
  messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "tenant", "PATCH", "{{capitalize name}}", updates, nil)
384
467
  {{/if}}
385
468
 
386
- c.JSON(http.StatusOK, gin.H{"message": "Patch successful"})
469
+ if ctrl.Config.GoDuck.Server.REST.Protocol == "messagepack" {
470
+ c.Render(http.StatusOK, render.MsgPack{Data: gin.H{"message": "Patch successful"}})
471
+ } else {
472
+ c.JSON(http.StatusOK, gin.H{"message": "Patch successful"})
473
+ }
387
474
  }
388
475
 
389
476
  func (ctrl *{{capitalize name}}Controller) BulkCreate(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"error": "Bulk operations use primary silo isolation by default."}) }
@@ -14,6 +14,8 @@ import (
14
14
  "{{app_name}}/integrations/wso2"
15
15
  "gorm.io/driver/postgres"
16
16
  "gorm.io/gorm"
17
+ "net/http"
18
+ "github.com/improbable-eng/grpc-web/go/grpcweb"
17
19
  )
18
20
 
19
21
  func main() {
@@ -40,6 +42,18 @@ func main() {
40
42
  go func() {
41
43
  grpcSrv := server.NewGRPCServer(appConfig, repo)
42
44
  logger.Info("Starting Kratos gRPC server on %s", appConfig.GoDuck.Server.GRPC.Addr)
45
+
46
+ if appConfig.GoDuck.Server.GRPC.WebEnabled {
47
+ wrappedGrpc := grpcweb.WrapServer(grpcSrv.Server)
48
+ go func() {
49
+ webPort := fmt.Sprintf(":%d", appConfig.GoDuck.Server.GRPC.WebPort)
50
+ logger.Info("Starting gRPC-Web Proxy on %s", webPort)
51
+ if err := http.ListenAndServe(webPort, wrappedGrpc); err != nil {
52
+ logger.Error("Failed to start gRPC-Web Proxy: %v", err)
53
+ }
54
+ }()
55
+ }
56
+
43
57
  if err := grpcSrv.Start(context.Background()); err != nil {
44
58
  logger.Error("Failed to start Kratos gRPC server: %v", err)
45
59
  }
@@ -56,7 +70,7 @@ func main() {
56
70
  }
57
71
 
58
72
  // 6. Start HTTP Server
59
- port := fmt.Sprintf(":%d", appConfig.GoDuck.Server.Port)
60
- logger.Info("Starting HTTP server on %s", port)
73
+ port := fmt.Sprintf(":%d", appConfig.GoDuck.Server.REST.Port)
74
+ logger.Info("Starting HTTP REST server on %s", port)
61
75
  r.Run(port)
62
76
  }
@@ -115,6 +115,21 @@ func SetupRouter(appConfig *config.Config) *gin.Engine {
115
115
  log.Printf("Warning: Failed to connect to fallback database for migration: %v", err)
116
116
  }
117
117
  }
118
+
119
+ // 10c. Migrate all provisioned tenant silos
120
+ var tenantRoles []models.TenantRole
121
+ if err := masterDB.Find(&tenantRoles).Error; err == nil {
122
+ mgr := middleware.GetTenantManager(masterDB, appConfig)
123
+ for _, tenant := range tenantRoles {
124
+ if tenantDB, err := mgr.GetDB(tenant.DBName); err == nil {
125
+ if err := migrations.RunGoNativeMigrationsForTenant(tenantDB); err != nil {
126
+ log.Printf("Warning: Failed to run migrations on tenant %s silo %s: %v", tenant.RoleName, tenant.DBName, err)
127
+ } else {
128
+ log.Printf("βœ… Migrated tenant silo: %s", tenant.DBName)
129
+ }
130
+ }
131
+ }
132
+ }
118
133
  }
119
134
  }
120
135
 
@@ -2,6 +2,7 @@ package service
2
2
 
3
3
  import (
4
4
  "context"
5
+ "strings"
5
6
  {{#if needFmt}}
6
7
  "fmt"
7
8
  {{/if}}
@@ -283,6 +284,15 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
283
284
  for role, db := range siloConns {
284
285
  var results []models.{{capitalize name}}
285
286
  opts := options.Find().SetSkip(int64((req.Page - 1) * req.PageSize)).SetLimit(int64(req.PageSize))
287
+ if req.Sort != "" {
288
+ parts := strings.Split(req.Sort, ",")
289
+ field := parts[0]
290
+ order := 1
291
+ if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
292
+ order = -1
293
+ }
294
+ opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
295
+ }
286
296
  cursor, err := db.Collection("{{toLowerCase name}}s").Find(ctx, bson.M{}, opts)
287
297
  if err == nil {
288
298
  cursor.All(ctx, &results)
@@ -300,6 +310,15 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
300
310
  if ok {
301
311
  var results []models.{{capitalize name}}
302
312
  opts := options.Find().SetSkip(int64((req.Page - 1) * req.PageSize)).SetLimit(int64(req.PageSize))
313
+ if req.Sort != "" {
314
+ parts := strings.Split(req.Sort, ",")
315
+ field := parts[0]
316
+ order := 1
317
+ if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" {
318
+ order = -1
319
+ }
320
+ opts.SetSort(bson.D{bson.E{Key: field, Value: order}})
321
+ }
303
322
  cursor, err := db.Collection("{{toLowerCase name}}s").Find(ctx, bson.M{}, opts)
304
323
  if err == nil {
305
324
  cursor.All(ctx, &results)
@@ -319,7 +338,9 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
319
338
  for role, db := range siloConns {
320
339
  var results []models.{{capitalize name}}
321
340
  query := db.WithContext(ctx).Model(&models.{{capitalize name}}{})
322
-
341
+ if req.Sort != "" {
342
+ query = query.Order(strings.ReplaceAll(req.Sort, ",", " "))
343
+ }
323
344
  var count int64
324
345
  query.Count(&count)
325
346
  total += count
@@ -338,7 +359,9 @@ func (s *{{capitalize name}}Service) List{{capitalize name}}(ctx context.Context
338
359
  if ok {
339
360
  var results []models.{{capitalize name}}
340
361
  query := db.WithContext(ctx).Model(&models.{{capitalize name}}{})
341
-
362
+ if req.Sort != "" {
363
+ query = query.Order(strings.ReplaceAll(req.Sort, ",", " "))
364
+ }
342
365
  var count int64
343
366
  query.Count(&count)
344
367
  total = count
@@ -83,6 +83,7 @@ message List{{capitalize name}}Request {
83
83
  int32 page = 1;
84
84
  int32 page_size = 2;
85
85
  string query = 3;
86
+ string sort = 4;
86
87
  }
87
88
 
88
89
  message List{{capitalize name}}Reply {