@restforgejs/platform 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/build-info.json +2 -2
  2. package/cli/consumer-deploy.js +1 -1
  3. package/cli/consumer.js +1 -1
  4. package/generators/cli/init.js +4 -104
  5. package/generators/cli/payload/migrate.js +1 -1
  6. package/generators/cli/schema/list.js +82 -18
  7. package/generators/cli/schema/migrate.js +23 -3
  8. package/generators/lib/dbschema-kit/diff-engine.js +715 -715
  9. package/generators/lib/migrate/field-type-resolver.js +9 -3
  10. package/generators/lib/migrate/migrate-runner.js +393 -187
  11. package/generators/lib/migrate/naming.js +9 -0
  12. package/generators/lib/templates/dashboard-catalog.js +1 -1
  13. package/generators/lib/templates/db-connection-env.js +1 -1
  14. package/generators/lib/templates/dbschema-catalog.js +1 -1
  15. package/generators/lib/templates/field-validation-catalog.js +1 -1
  16. package/generators/lib/templates/mysql-template.js +1 -1
  17. package/generators/lib/templates/oracle-template.js +1 -1
  18. package/generators/lib/templates/postgres-template.js +1 -1
  19. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  20. package/generators/lib/templates/sqlite-template.js +1 -1
  21. package/integrity-manifest.json +18 -18
  22. package/package.json +1 -1
  23. package/scripts/verify-integrity.js +1 -1
  24. package/server.js +1 -1
  25. package/src/components/handlers/adjust_handler.js +1 -1
  26. package/src/components/handlers/audit_handler.js +1 -1
  27. package/src/components/handlers/delete_handler.js +1 -1
  28. package/src/components/handlers/export_handler.js +1 -1
  29. package/src/components/handlers/import_handler.js +1 -1
  30. package/src/components/handlers/insert_handler.js +1 -1
  31. package/src/components/handlers/update_handler.js +1 -1
  32. package/src/components/handlers/upload_handler.js +1 -1
  33. package/src/components/handlers/workflow_handler.js +1 -1
  34. package/src/components/integrations/webhook.js +1 -1
  35. package/src/consumers/baseConsumer.js +1 -1
  36. package/src/consumers/declarativeMapper.js +1 -1
  37. package/src/consumers/handlers/apiHandler.js +1 -1
  38. package/src/consumers/handlers/consoleHandler.js +1 -1
  39. package/src/consumers/handlers/databaseHandler.js +1 -1
  40. package/src/consumers/handlers/index.js +1 -1
  41. package/src/consumers/handlers/kafkaHandler.js +1 -1
  42. package/src/consumers/index.js +1 -1
  43. package/src/consumers/messageTransformer.js +1 -1
  44. package/src/consumers/validator.js +1 -1
  45. package/src/core/db/dialect/base-dialect.js +1 -1
  46. package/src/core/db/dialect/index.js +1 -1
  47. package/src/core/db/dialect/mysql-dialect.js +1 -1
  48. package/src/core/db/dialect/oracle-dialect.js +1 -1
  49. package/src/core/db/dialect/postgres-dialect.js +1 -1
  50. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  51. package/src/core/db/flatten-helper.js +1 -1
  52. package/src/core/db/query-builder-error.js +1 -1
  53. package/src/core/db/query-builder.js +1 -1
  54. package/src/core/db/relation-helper.js +1 -1
  55. package/src/core/handlers/delete_handler.js +1 -1
  56. package/src/core/handlers/insert_handler.js +1 -1
  57. package/src/core/handlers/update_handler.js +1 -1
  58. package/src/core/models/base-model.js +1 -1
  59. package/src/core/utils/cache-manager.js +1 -1
  60. package/src/core/utils/component-engine.js +1 -1
  61. package/src/core/utils/context-builder.js +1 -1
  62. package/src/core/utils/datetime-formatter.js +1 -1
  63. package/src/core/utils/datetime-parser.js +1 -1
  64. package/src/core/utils/db.js +1 -1
  65. package/src/core/utils/logger.js +1 -1
  66. package/src/core/utils/payload-loader.js +1 -1
  67. package/src/core/utils/security-checks.js +1 -1
  68. package/src/middleware/body-options.js +1 -1
  69. package/src/middleware/cors.js +1 -1
  70. package/src/middleware/idempotency.js +1 -1
  71. package/src/middleware/rate-limiter.js +1 -1
  72. package/src/middleware/request-logger.js +1 -1
  73. package/src/middleware/security-headers.js +1 -1
  74. package/src/models/base-model-mysql.js +1 -1
  75. package/src/models/base-model-oracle.js +1 -1
  76. package/src/models/base-model-sqlite.js +1 -1
  77. package/src/models/base-model.js +1 -1
  78. package/src/pro/caching/redis-client.js +1 -1
  79. package/src/pro/caching/redis-helper.js +1 -1
  80. package/src/pro/consumers/baseConsumer.js +1 -1
  81. package/src/pro/consumers/declarativeMapper.js +1 -1
  82. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  83. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  84. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  85. package/src/pro/consumers/handlers/index.js +1 -1
  86. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  87. package/src/pro/consumers/index.js +1 -1
  88. package/src/pro/consumers/messageTransformer.js +1 -1
  89. package/src/pro/consumers/validator.js +1 -1
  90. package/src/pro/database/base-model-mysql.js +1 -1
  91. package/src/pro/database/base-model-oracle.js +1 -1
  92. package/src/pro/database/base-model-sqlite.js +1 -1
  93. package/src/pro/database/db-mysql.js +1 -1
  94. package/src/pro/database/db-oracle.js +1 -1
  95. package/src/pro/database/db-sqlite.js +1 -1
  96. package/src/pro/excel/excel-generator.js +1 -1
  97. package/src/pro/excel/excel-parser.js +1 -1
  98. package/src/pro/excel/export-service.js +1 -1
  99. package/src/pro/excel/export_handler.js +1 -1
  100. package/src/pro/excel/import-service.js +1 -1
  101. package/src/pro/excel/import-validator.js +1 -1
  102. package/src/pro/excel/import_handler.js +1 -1
  103. package/src/pro/excel/upsert-builder.js +1 -1
  104. package/src/pro/idgen/idgen-routes.js +1 -1
  105. package/src/pro/integrations/lookup-resolver.js +1 -1
  106. package/src/pro/integrations/upload-handler-v2.js +1 -1
  107. package/src/pro/integrations/upload-handler.js +1 -1
  108. package/src/pro/integrations/webhook.js +1 -1
  109. package/src/pro/locking/lock-routes.js +1 -1
  110. package/src/pro/locking/resource-lock-manager.js +1 -1
  111. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  112. package/src/pro/messaging/kafkaService.js +1 -1
  113. package/src/pro/messaging/messagehubService.js +1 -1
  114. package/src/pro/messaging/rabbitmqService.js +1 -1
  115. package/src/pro/scheduler/job-manager.js +1 -1
  116. package/src/pro/scheduler/job-routes.js +1 -1
  117. package/src/pro/scheduler/job-validator.js +1 -1
  118. package/src/pro/storage/base-storage-provider.js +1 -1
  119. package/src/pro/storage/file-metadata-helper.js +1 -1
  120. package/src/pro/storage/index.js +1 -1
  121. package/src/pro/storage/local-storage-provider.js +1 -1
  122. package/src/pro/storage/s3-storage-provider.js +1 -1
  123. package/src/pro/storage/upload-cleanup-job.js +1 -1
  124. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  125. package/src/pro/storage/upload-pending-tracker.js +1 -1
  126. package/src/pro/websocket/broadcast-helper.js +1 -1
  127. package/src/pro/websocket/index.js +1 -1
  128. package/src/pro/websocket/livesync-server.js +1 -1
  129. package/src/pro/websocket/ws-broadcaster.js +1 -1
  130. package/src/services/export-service.js +1 -1
  131. package/src/services/import-service.js +1 -1
  132. package/src/services/kafkaConsumerService.js +1 -1
  133. package/src/services/kafkaService.js +1 -1
  134. package/src/services/messagehubService.js +1 -1
  135. package/src/services/rabbitmqService.js +1 -1
  136. package/src/utils/cache-invalidation-registry.js +1 -1
  137. package/src/utils/cache-manager.js +1 -1
  138. package/src/utils/component-engine.js +1 -1
  139. package/src/utils/config-extractor.js +1 -1
  140. package/src/utils/consumerLogger.js +1 -1
  141. package/src/utils/context-builder.js +1 -1
  142. package/src/utils/dashboard-helpers.js +1 -1
  143. package/src/utils/dateHelper.js +1 -1
  144. package/src/utils/datetime-formatter.js +1 -1
  145. package/src/utils/datetime-parser.js +1 -1
  146. package/src/utils/db-bootstrap.js +1 -1
  147. package/src/utils/db-mysql.js +1 -1
  148. package/src/utils/db-oracle.js +1 -1
  149. package/src/utils/db-sqlite.js +1 -1
  150. package/src/utils/db.js +1 -1
  151. package/src/utils/demo-generator.js +1 -1
  152. package/src/utils/excel-generator.js +1 -1
  153. package/src/utils/excel-parser.js +1 -1
  154. package/src/utils/file-watcher.js +1 -1
  155. package/src/utils/id-generator.js +1 -1
  156. package/src/utils/idempotency-manager.js +1 -1
  157. package/src/utils/import-validator.js +1 -1
  158. package/src/utils/license-client.js +1 -1
  159. package/src/utils/lock-manager.js +1 -1
  160. package/src/utils/logger.js +1 -1
  161. package/src/utils/lookup-resolver.js +1 -1
  162. package/src/utils/payload-loader.js +1 -1
  163. package/src/utils/processor-response.js +1 -1
  164. package/src/utils/rabbitmq.js +1 -1
  165. package/src/utils/redis-client.js +1 -1
  166. package/src/utils/redis-helper.js +1 -1
  167. package/src/utils/request-scope.js +1 -1
  168. package/src/utils/security-checks.js +1 -1
  169. package/src/utils/service-resolver.js +1 -1
  170. package/src/utils/shutdown-coordinator.js +1 -1
  171. package/src/utils/trusted-keys.js +1 -1
  172. package/src/utils/upload-handler.js +1 -1
  173. package/src/utils/upsert-builder.js +1 -1
  174. package/src/utils/workflow-hook-executor.js +1 -1
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  const { generateLabel } = require('./label-generator');
21
- const { snakeToTitle } = require('./naming');
21
+ const { snakeToTitle, snakeToKebab } = require('./naming');
22
22
 
23
23
  const AUDIT_FIELDS = ['created_at', 'created_by', 'updated_at', 'updated_by'];
24
24
  const TEXTAREA_FIELDS = ['address', 'description', 'notes'];
@@ -175,7 +175,10 @@ class FieldTypeResolver {
175
175
  // Rule 3: FK with JOIN → select (API)
176
176
  if (isFk) {
177
177
  const join = this.joinMap.get(fieldName);
178
- const displayCol = guessDisplayCol(join);
178
+ // Display column diambil dari kolom JOIN aktual yang dipilih di SELECT
179
+ // (mis. category_name), bukan tebakan `<table>_name`, sehingga `select`
180
+ // dan `tableField` konsisten.
181
+ const displayCol = this.joinDisplayFields.get(fieldName) || guessDisplayCol(join);
179
182
  return {
180
183
  name: fieldName,
181
184
  label,
@@ -189,7 +192,10 @@ class FieldTypeResolver {
189
192
  extra: {
190
193
  dataSource: {
191
194
  type: 'api',
192
- resource: join.tableName,
195
+ // resource = kebab-case agar cocok dengan apiPath endpoint
196
+ // (POST <apiBaseUrl>/<resource>/lookup). tableName SQL bersifat
197
+ // snake_case (visitor_categories) sehingga harus dikonversi.
198
+ resource: snakeToKebab(join.tableName),
193
199
  select: [join.remoteColumn, displayCol]
194
200
  }
195
201
  }
@@ -1,187 +1,393 @@
1
- 'use strict';
2
-
3
- /**
4
- * Migrate Runner — orchestrator untuk command `payload migrate`.
5
- *
6
- * Tanggung jawab:
7
- * 1. Resolve file config (.env) via cascade lookup standar platform
8
- * 2. Baca SERVER_ADDRESS dan SERVER_PORT dari config → konstruksi apiBaseUrl
9
- * dengan format `http://{host}:{port}/api/{project}`
10
- * 3. Load file payload backend (RDF) dari --name (relative ke cwd/payload/
11
- * atau absolute)
12
- * 4. Jalankan migrasi RDF UDF via BackendPayloadMigrator
13
- * 5. Tulis output JSON ke directory --output (file name = basename tanpa ext +
14
- * suffix `-app.json`)
15
- */
16
-
17
- const fs = require('fs');
18
- const path = require('path');
19
-
20
- const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
21
- const { readEnvFile } = require('../utils/env-manager');
22
- const { migrate } = require('./backend-payload-migrator');
23
-
24
- const DEFAULT_APP_NAME = 'My Application';
25
- const DEFAULT_APP_CODE_FALLBACK = 'my-app';
26
- const DEFAULT_PLUGIN = 'vanilla-js-basic';
27
- const DEFAULT_HOST = '127.0.0.1';
28
- const DEFAULT_BACKEND_PORT = 3000;
29
- const DEFAULT_FRONTEND_PORT = 8000;
30
-
31
- function resolveInputPath(nameArg, cwd) {
32
- if (path.isAbsolute(nameArg)) return nameArg;
33
- const direct = path.resolve(cwd, nameArg);
34
- if (fs.existsSync(direct)) return direct;
35
- const inPayload = path.resolve(cwd, 'payload', nameArg);
36
- if (fs.existsSync(inPayload)) return inPayload;
37
- // Fallback: return cwd-resolved path so caller dapat tampilkan error path
38
- return direct;
39
- }
40
-
41
- function buildOutputPath(outputArg, inputBaseName, cwd) {
42
- const targetFileName = inputBaseName;
43
-
44
- if (!outputArg) {
45
- const defaultDir = path.resolve(cwd, 'frontend', 'payload');
46
- return path.join(defaultDir, targetFileName);
47
- }
48
-
49
- const abs = path.isAbsolute(outputArg) ? outputArg : path.resolve(cwd, outputArg);
50
-
51
- // Treat as file path bila berakhiran .json (case insensitive)
52
- if (/\.json$/i.test(abs)) return abs;
53
-
54
- // Else treat as directory
55
- return path.join(abs, targetFileName);
56
- }
57
-
58
- function readServerConfig(envFilePath) {
59
- const { data } = readEnvFile(envFilePath);
60
- const host = (typeof data.SERVER_ADDRESS === 'string' && data.SERVER_ADDRESS.length > 0)
61
- ? data.SERVER_ADDRESS
62
- : DEFAULT_HOST;
63
- const portRaw = (typeof data.SERVER_PORT === 'string' && data.SERVER_PORT.length > 0)
64
- ? data.SERVER_PORT
65
- : '';
66
- const portNum = parseInt(portRaw, 10);
67
- const port = Number.isFinite(portNum) && portNum > 0 ? portNum : DEFAULT_BACKEND_PORT;
68
- return { host, port };
69
- }
70
-
71
- function buildApiBaseUrl(host, port, project) {
72
- const projectSegment = project ? `/${project}` : '';
73
- return `http://${host}:${port}/api${projectSegment}`;
74
- }
75
-
76
- async function run(args) {
77
- const cwd = process.cwd();
78
-
79
- if (!args.name || typeof args.name !== 'string') {
80
- const err = new Error('Flag --name is required (backend payload file name, e.g. visitors.json)');
81
- err.exitCode = 2;
82
- throw err;
83
- }
84
- if (!args.project || typeof args.project !== 'string') {
85
- const err = new Error('Flag --project is required (project name used in apiBaseUrl)');
86
- err.exitCode = 2;
87
- throw err;
88
- }
89
-
90
- const resolved = resolveConfig(args.config, cwd);
91
- if (!resolved) {
92
- const err = new Error('Flag --config is required, or set a default config via `restforge config set-default`');
93
- err.exitCode = 2;
94
- throw err;
95
- }
96
- if (resolved.source === 'default') {
97
- printDefaultConfigWarning(resolved.defaultName);
98
- }
99
- if (!fs.existsSync(resolved.path)) {
100
- const err = new Error(`Config file not found: ${resolved.path}`);
101
- err.exitCode = 3;
102
- throw err;
103
- }
104
-
105
- const { host, port: backendPort } = readServerConfig(resolved.path);
106
- const project = args.project;
107
- const apiBaseUrl = buildApiBaseUrl(host, backendPort, project);
108
-
109
- const frontendPortArg = (typeof args.port === 'number' && Number.isFinite(args.port) && args.port > 0)
110
- ? args.port
111
- : DEFAULT_FRONTEND_PORT;
112
-
113
- const appName = args.appName || DEFAULT_APP_NAME;
114
- const appCode = args.appCode || project || DEFAULT_APP_CODE_FALLBACK;
115
- const plugin = args.plugin || DEFAULT_PLUGIN;
116
-
117
- const inputPath = resolveInputPath(args.name, cwd);
118
- if (!fs.existsSync(inputPath)) {
119
- const err = new Error(`Input payload file not found: ${inputPath}`);
120
- err.exitCode = 3;
121
- throw err;
122
- }
123
- let backendPayload;
124
- try {
125
- const content = fs.readFileSync(inputPath, 'utf8');
126
- backendPayload = JSON.parse(content);
127
- } catch (e) {
128
- const err = new Error(`Failed to parse JSON from ${inputPath}: ${e.message}`);
129
- err.exitCode = 3;
130
- throw err;
131
- }
132
-
133
- const inputBaseName = path.basename(inputPath);
134
- const outputPath = buildOutputPath(args.output, inputBaseName, cwd);
135
-
136
- if (fs.existsSync(outputPath) && !args.overwrite) {
137
- const err = new Error(`Output file already exists: ${outputPath}. Use --overwrite to replace.`);
138
- err.exitCode = 1;
139
- throw err;
140
- }
141
-
142
- const result = migrate([backendPayload], appName, appCode, plugin, apiBaseUrl, frontendPortArg);
143
- if (!result.success) {
144
- const msg = (result.errors && result.errors.length > 0)
145
- ? result.errors.join('; ')
146
- : 'Migration failed without details';
147
- const err = new Error(msg);
148
- err.exitCode = 1;
149
- throw err;
150
- }
151
-
152
- const outputDir = path.dirname(outputPath);
153
- if (!fs.existsSync(outputDir)) {
154
- fs.mkdirSync(outputDir, { recursive: true });
155
- }
156
- fs.writeFileSync(outputPath, JSON.stringify(result.payload, null, 2), 'utf8');
157
-
158
- const stdout = process.stdout;
159
- stdout.write('============================================================\n');
160
- stdout.write('PAYLOAD MIGRATE - RDF (backend) -> UDF (frontend)\n');
161
- stdout.write('============================================================\n\n');
162
- stdout.write(` Input : ${inputPath}\n`);
163
- stdout.write(` Output : ${outputPath}\n`);
164
- stdout.write(` Project : ${project}\n`);
165
- stdout.write(` apiBaseUrl : ${apiBaseUrl}\n`);
166
- stdout.write(` Backend port : ${backendPort}\n`);
167
- stdout.write(` Frontend port: ${frontendPortArg}\n`);
168
- stdout.write(` Pages : ${result.pageResults.length}\n\n`);
169
- for (const pr of result.pageResults) {
170
- stdout.write(` [OK] ${pr.pageId}: ${pr.fieldCount} field(s), ${pr.tableColCount} table column(s)\n`);
171
- }
172
- if (result.warnings && result.warnings.length > 0) {
173
- stdout.write('\n Warnings:\n');
174
- for (const w of result.warnings) {
175
- stdout.write(` - ${w}\n`);
176
- }
177
- }
178
- stdout.write('\n Migration completed successfully.\n');
179
- }
180
-
181
- module.exports = {
182
- run,
183
- resolveInputPath,
184
- buildOutputPath,
185
- readServerConfig,
186
- buildApiBaseUrl
187
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migrate Runner — orchestrator untuk command `payload migrate`.
5
+ *
6
+ * Tanggung jawab:
7
+ * 1. Resolve file config (.env) via cascade lookup standar platform
8
+ * 2. Baca SERVER_ADDRESS dan SERVER_PORT dari config → konstruksi apiBaseUrl
9
+ * dengan format `http://{host}:{port}/api/{project}`
10
+ * 3. Load file payload backend (RDF) dari --name (relative ke cwd/payload/
11
+ * atau absolute) dan resolve referensi `file:` pada datatablesQuery/viewQuery
12
+ * 4. Discovery RDF terkait via JOIN di datatablesQuery: setiap tabel yang
13
+ * di-JOIN dimuat RDF-nya dari folder yang sama lalu dikonversi menjadi page
14
+ * tersendiri (1 RDF utama → multi page)
15
+ * 5. Jalankan migrasi RDF → UDF via BackendPayloadMigrator
16
+ * 6. Tulis output dalam format SPLIT multi-file:
17
+ * <output>/app-config.json → { appConfig }
18
+ * <output>/pages/<pageId>.json → { pages: [ <page> ] }
19
+ * <output>/<appCode>.json → aggregator (extends/homepage/include/navigation)
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
26
+ const { readEnvFile } = require('../utils/env-manager');
27
+ const { migrate } = require('./backend-payload-migrator');
28
+ const { parseDatatablesQuery } = require('./sql-parser');
29
+ const { kebabToTitle, snakeToKebab } = require('./naming');
30
+
31
+ const DEFAULT_APP_NAME = 'My Application';
32
+ const DEFAULT_APP_CODE_FALLBACK = 'my-app';
33
+ const DEFAULT_PLUGIN = 'vanilla-js-basic';
34
+ const DEFAULT_HOST = '127.0.0.1';
35
+ const DEFAULT_BACKEND_PORT = 3000;
36
+ const DEFAULT_FRONTEND_PORT = 8000;
37
+
38
+ const QUERY_REF_FIELDS = ['datatablesQuery', 'viewQuery'];
39
+
40
+ function resolveInputPath(nameArg, cwd) {
41
+ if (path.isAbsolute(nameArg)) return nameArg;
42
+ const direct = path.resolve(cwd, nameArg);
43
+ if (fs.existsSync(direct)) return direct;
44
+ const inPayload = path.resolve(cwd, 'payload', nameArg);
45
+ if (fs.existsSync(inPayload)) return inPayload;
46
+ // Fallback: return cwd-resolved path so caller dapat tampilkan error path
47
+ return direct;
48
+ }
49
+
50
+ function buildOutputDir(outputArg, cwd) {
51
+ if (!outputArg) {
52
+ return path.resolve(cwd, 'frontend', 'payload');
53
+ }
54
+ const abs = path.isAbsolute(outputArg) ? outputArg : path.resolve(cwd, outputArg);
55
+ // Toleransi bila --output diakhiri .json: pakai direktori induknya sebagai
56
+ // root output split (output bukan file tunggal lagi).
57
+ if (/\.json$/i.test(abs)) return path.dirname(abs);
58
+ return abs;
59
+ }
60
+
61
+ function readServerConfig(envFilePath) {
62
+ const { data } = readEnvFile(envFilePath);
63
+ const host = (typeof data.SERVER_ADDRESS === 'string' && data.SERVER_ADDRESS.length > 0)
64
+ ? data.SERVER_ADDRESS
65
+ : DEFAULT_HOST;
66
+ const portRaw = (typeof data.SERVER_PORT === 'string' && data.SERVER_PORT.length > 0)
67
+ ? data.SERVER_PORT
68
+ : '';
69
+ const portNum = parseInt(portRaw, 10);
70
+ const port = Number.isFinite(portNum) && portNum > 0 ? portNum : DEFAULT_BACKEND_PORT;
71
+ return { host, port };
72
+ }
73
+
74
+ function buildApiBaseUrl(host, port, project) {
75
+ const projectSegment = project ? `/${project}` : '';
76
+ return `http://${host}:${port}/api${projectSegment}`;
77
+ }
78
+
79
+ /**
80
+ * Gabungkan aggregator `<project>.json` yang sudah ada dengan hasil run saat ini.
81
+ * Karena nama file aggregator = nama project, run berikutnya untuk project yang
82
+ * sama akan menambahkan (akumulasi) page baru, bukan menimpa.
83
+ *
84
+ * Aturan merge:
85
+ * - `pages[]` : union berdasarkan path `include` (entry existing didahulukan,
86
+ * page baru di-append). Re-migrate page yang sama = idempoten.
87
+ * - `navigation.items`: union berdasarkan `pageRef` (type=page). Item existing
88
+ * dipertahankan sehingga kustomisasi manual (mis. icon/label)
89
+ * pada page lama tidak hilang.
90
+ * - `homepage` : dipertahankan dari file existing (run pertama yang menetapkan).
91
+ * - `extends` : dipertahankan.
92
+ */
93
+ function mergeAggregator(existing, fresh) {
94
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
95
+ return fresh;
96
+ }
97
+
98
+ const seenInclude = new Set();
99
+ const mergedPages = [];
100
+ const pushPages = (list) => {
101
+ if (!Array.isArray(list)) return;
102
+ for (const entry of list) {
103
+ const key = (entry && typeof entry.include === 'string')
104
+ ? entry.include
105
+ : JSON.stringify(entry);
106
+ if (seenInclude.has(key)) continue;
107
+ seenInclude.add(key);
108
+ mergedPages.push(entry);
109
+ }
110
+ };
111
+ pushPages(existing.pages);
112
+ pushPages(fresh.pages);
113
+
114
+ const seenNav = new Set();
115
+ const mergedNav = [];
116
+ const pushNav = (items) => {
117
+ if (!Array.isArray(items)) return;
118
+ for (const item of items) {
119
+ const key = (item && item.type === 'page' && item.pageRef)
120
+ ? `page:${item.pageRef}`
121
+ : JSON.stringify(item);
122
+ if (seenNav.has(key)) continue;
123
+ seenNav.add(key);
124
+ mergedNav.push(item);
125
+ }
126
+ };
127
+ const existingNav = (existing.navigation && Array.isArray(existing.navigation.items))
128
+ ? existing.navigation.items : [];
129
+ const freshNav = (fresh.navigation && Array.isArray(fresh.navigation.items))
130
+ ? fresh.navigation.items : [];
131
+ pushNav(existingNav);
132
+ pushNav(freshNav);
133
+
134
+ return {
135
+ extends: existing.extends || fresh.extends,
136
+ homepage: (typeof existing.homepage === 'string' && existing.homepage)
137
+ ? existing.homepage
138
+ : fresh.homepage,
139
+ pages: mergedPages,
140
+ navigation: { items: mergedNav }
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Resolve referensi `file:` pada query fields (datatablesQuery, viewQuery)
146
+ * menjadi isi SQL inline. Dibaca langsung dari disk relatif terhadap lokasi
147
+ * file RDF, independen dari mode pkg (file SQL ada di project user, bukan
148
+ * di-bundle ke binary). Bila file tidak ditemukan, query di-kosongkan dan
149
+ * sebuah warning dikumpulkan agar parsing JOIN tidak crash.
150
+ */
151
+ function resolveQueryRefs(payload, payloadPath, warnings) {
152
+ const dir = path.dirname(payloadPath);
153
+ const out = Object.assign({}, payload);
154
+ for (const key of QUERY_REF_FIELDS) {
155
+ const val = out[key];
156
+ if (typeof val === 'string' && val.startsWith('file:')) {
157
+ const rel = val.slice(5);
158
+ const full = path.isAbsolute(rel) ? rel : path.resolve(dir, rel);
159
+ if (fs.existsSync(full)) {
160
+ out[key] = fs.readFileSync(full, 'utf8');
161
+ } else {
162
+ out[key] = '';
163
+ warnings.push(`${key} reference not found: ${full} (referenced by ${path.basename(payloadPath)})`);
164
+ }
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function loadRdf(filePath) {
171
+ const content = fs.readFileSync(filePath, 'utf8');
172
+ return JSON.parse(content);
173
+ }
174
+
175
+ /**
176
+ * Petakan nama tabel JOIN (snake_case, mis. visitor_categories) ke file RDF
177
+ * sibling di folder payload. Coba bentuk kebab dulu (visitor-categories.json),
178
+ * lalu bentuk snake (visitor_categories.json).
179
+ */
180
+ function findRelatedRdfPath(tableName, payloadDir) {
181
+ const candidates = [`${snakeToKebab(tableName)}.json`, `${tableName}.json`];
182
+ for (const c of candidates) {
183
+ const p = path.join(payloadDir, c);
184
+ if (fs.existsSync(p)) return { path: p, candidates };
185
+ }
186
+ return { path: null, candidates };
187
+ }
188
+
189
+ async function run(args) {
190
+ const cwd = process.cwd();
191
+
192
+ if (!args.name || typeof args.name !== 'string') {
193
+ const err = new Error('Flag --name is required (backend payload file name, e.g. visitors.json)');
194
+ err.exitCode = 2;
195
+ throw err;
196
+ }
197
+ if (!args.project || typeof args.project !== 'string') {
198
+ const err = new Error('Flag --project is required (project name used in apiBaseUrl)');
199
+ err.exitCode = 2;
200
+ throw err;
201
+ }
202
+
203
+ const resolved = resolveConfig(args.config, cwd);
204
+ if (!resolved) {
205
+ const err = new Error('Flag --config is required, or set a default config via `restforge config set-default`');
206
+ err.exitCode = 2;
207
+ throw err;
208
+ }
209
+ if (resolved.source === 'default') {
210
+ printDefaultConfigWarning(resolved.defaultName);
211
+ }
212
+ if (!fs.existsSync(resolved.path)) {
213
+ const err = new Error(`Config file not found: ${resolved.path}`);
214
+ err.exitCode = 3;
215
+ throw err;
216
+ }
217
+
218
+ const { host, port: backendPort } = readServerConfig(resolved.path);
219
+ const project = args.project;
220
+ const apiBaseUrl = buildApiBaseUrl(host, backendPort, project);
221
+
222
+ const frontendPortArg = (typeof args.port === 'number' && Number.isFinite(args.port) && args.port > 0)
223
+ ? args.port
224
+ : DEFAULT_FRONTEND_PORT;
225
+
226
+ const appName = args.appName || kebabToTitle(project) || DEFAULT_APP_NAME;
227
+ const appCode = args.appCode || project || DEFAULT_APP_CODE_FALLBACK;
228
+ const plugin = args.plugin || DEFAULT_PLUGIN;
229
+
230
+ const inputPath = resolveInputPath(args.name, cwd);
231
+ if (!fs.existsSync(inputPath)) {
232
+ const err = new Error(`Input payload file not found: ${inputPath}`);
233
+ err.exitCode = 3;
234
+ throw err;
235
+ }
236
+
237
+ const warnings = [];
238
+
239
+ let mainPayloadRaw;
240
+ try {
241
+ mainPayloadRaw = loadRdf(inputPath);
242
+ } catch (e) {
243
+ const err = new Error(`Failed to parse JSON from ${inputPath}: ${e.message}`);
244
+ err.exitCode = 3;
245
+ throw err;
246
+ }
247
+
248
+ // Resolve file: refs pada RDF utama agar JOIN dapat di-parse
249
+ const mainPayload = resolveQueryRefs(mainPayloadRaw, inputPath, warnings);
250
+ const mainTableClean = String(mainPayload.tableName || '').split('.').pop() || '';
251
+
252
+ // Discovery tabel terkait via JOIN (1 level dari RDF utama)
253
+ const payloadDir = path.dirname(inputPath);
254
+ const parsedMain = parseDatatablesQuery(mainPayload.datatablesQuery || '');
255
+ const relatedPayloads = [];
256
+ const seenTables = new Set([mainTableClean]);
257
+ for (const join of parsedMain.joins) {
258
+ const t = String(join.tableName || '').split('.').pop() || '';
259
+ if (!t || seenTables.has(t)) continue;
260
+ seenTables.add(t);
261
+
262
+ const { path: relPath, candidates } = findRelatedRdfPath(t, payloadDir);
263
+ if (!relPath) {
264
+ warnings.push(`Related table '${t}' referenced by JOIN but no RDF file found in ${payloadDir} (tried: ${candidates.join(', ')}); page not generated, only the select reference is kept`);
265
+ continue;
266
+ }
267
+ try {
268
+ const relRaw = loadRdf(relPath);
269
+ relatedPayloads.push(resolveQueryRefs(relRaw, relPath, warnings));
270
+ } catch (e) {
271
+ warnings.push(`Failed to parse related RDF ${relPath}: ${e.message}`);
272
+ }
273
+ }
274
+
275
+ // Urutan page: tabel terkait (master) lebih dulu, RDF utama (detail) terakhir
276
+ const orderedPayloads = relatedPayloads.concat([mainPayload]);
277
+
278
+ const result = migrate(orderedPayloads, appName, appCode, plugin, apiBaseUrl, frontendPortArg);
279
+ if (!result.success) {
280
+ const msg = (result.errors && result.errors.length > 0)
281
+ ? result.errors.join('; ')
282
+ : 'Migration failed without details';
283
+ const err = new Error(msg);
284
+ err.exitCode = 1;
285
+ throw err;
286
+ }
287
+ for (const w of (result.warnings || [])) warnings.push(w);
288
+
289
+ const pages = result.payload.pages;
290
+ const appConfig = result.payload.appConfig;
291
+ // homepage = page dari RDF utama (entry terakhir karena di-append paling akhir)
292
+ const mainPageId = pages.length > 0 ? pages[pages.length - 1].pageId : '';
293
+
294
+ const outputDir = buildOutputDir(args.output, cwd);
295
+
296
+ // Susun semua file target untuk format split
297
+ const aggregatorPath = path.join(outputDir, `${appCode}.json`);
298
+
299
+ // File app-config + per-page: tunduk pada gate overwrite.
300
+ const pageTargets = [];
301
+ pageTargets.push({
302
+ path: path.join(outputDir, 'app-config.json'),
303
+ content: { appConfig }
304
+ });
305
+ for (const page of pages) {
306
+ pageTargets.push({
307
+ path: path.join(outputDir, 'pages', `${page.pageId}.json`),
308
+ content: { pages: [page] }
309
+ });
310
+ }
311
+
312
+ // Gate overwrite hanya untuk app-config + pages. Aggregator TIDAK dihitung
313
+ // sebagai konflik karena ia di-merge (akumulasi), bukan ditimpa.
314
+ if (!args.overwrite) {
315
+ const existing = pageTargets.filter(t => fs.existsSync(t.path)).map(t => t.path);
316
+ if (existing.length > 0) {
317
+ const err = new Error(`Output file(s) already exist:\n ${existing.join('\n ')}\nUse --overwrite to replace.`);
318
+ err.exitCode = 1;
319
+ throw err;
320
+ }
321
+ }
322
+
323
+ // Aggregator: gabungkan dengan file existing bila project sama (nama file sama)
324
+ const freshAggregator = {
325
+ extends: 'app-config.json',
326
+ homepage: mainPageId,
327
+ pages: pages.map(p => ({ include: `pages/${p.pageId}.json` })),
328
+ navigation: {
329
+ items: pages.map(p => ({ type: 'page', pageRef: p.pageId, label: p.pageTitle }))
330
+ }
331
+ };
332
+ let aggregatorMerged = false;
333
+ let aggregatorContent = freshAggregator;
334
+ if (fs.existsSync(aggregatorPath)) {
335
+ try {
336
+ const existingAgg = JSON.parse(fs.readFileSync(aggregatorPath, 'utf8'));
337
+ aggregatorContent = mergeAggregator(existingAgg, freshAggregator);
338
+ aggregatorMerged = true;
339
+ } catch (e) {
340
+ warnings.push(`Existing aggregator ${path.basename(aggregatorPath)} could not be parsed (${e.message}); it will be replaced`);
341
+ }
342
+ }
343
+
344
+ const targets = pageTargets.concat([{ path: aggregatorPath, content: aggregatorContent }]);
345
+ for (const t of targets) {
346
+ const dir = path.dirname(t.path);
347
+ if (!fs.existsSync(dir)) {
348
+ fs.mkdirSync(dir, { recursive: true });
349
+ }
350
+ fs.writeFileSync(t.path, JSON.stringify(t.content, null, 2), 'utf8');
351
+ }
352
+
353
+ const stdout = process.stdout;
354
+ stdout.write('============================================================\n');
355
+ stdout.write('PAYLOAD MIGRATE - RDF (backend) -> UDF (frontend, split)\n');
356
+ stdout.write('============================================================\n\n');
357
+ stdout.write(` Input : ${inputPath}\n`);
358
+ stdout.write(` Output dir : ${outputDir}\n`);
359
+ stdout.write(` Project : ${project}\n`);
360
+ stdout.write(` apiBaseUrl : ${apiBaseUrl}\n`);
361
+ stdout.write(` Backend port : ${backendPort}\n`);
362
+ stdout.write(` Frontend port: ${frontendPortArg}\n`);
363
+ stdout.write(` Homepage : ${aggregatorContent.homepage}\n`);
364
+ stdout.write(` Pages (run) : ${result.pageResults.length}\n`);
365
+ stdout.write(` Pages (app) : ${aggregatorContent.pages.length}${aggregatorMerged ? ' (merged with existing)' : ''}\n\n`);
366
+ for (const pr of result.pageResults) {
367
+ stdout.write(` [OK] ${pr.pageId}: ${pr.fieldCount} field(s), ${pr.tableColCount} table column(s)\n`);
368
+ }
369
+ stdout.write('\n Files written:\n');
370
+ for (const t of targets) {
371
+ const rel = path.relative(outputDir, t.path) || path.basename(t.path);
372
+ const tag = (t.path === aggregatorPath && aggregatorMerged) ? ' (merged)' : '';
373
+ stdout.write(` - ${rel}${tag}\n`);
374
+ }
375
+ if (warnings.length > 0) {
376
+ stdout.write('\n Warnings:\n');
377
+ for (const w of warnings) {
378
+ stdout.write(` - ${w}\n`);
379
+ }
380
+ }
381
+ stdout.write('\n Migration completed successfully.\n');
382
+ }
383
+
384
+ module.exports = {
385
+ run,
386
+ resolveInputPath,
387
+ buildOutputDir,
388
+ resolveQueryRefs,
389
+ findRelatedRdfPath,
390
+ readServerConfig,
391
+ buildApiBaseUrl,
392
+ mergeAggregator
393
+ };
@@ -29,6 +29,14 @@ function snakeToTitle(name) {
29
29
  return String(name || '').split('_').map(capitalize).join(' ');
30
30
  }
31
31
 
32
+ function kebabToTitle(name) {
33
+ return String(name || '')
34
+ .split(/[-_]+/)
35
+ .filter(Boolean)
36
+ .map(capitalize)
37
+ .join(' ');
38
+ }
39
+
32
40
  function toClassName(appCode) {
33
41
  return String(appCode || '').replace(/_/g, '-').split('-').map(capitalize).join('');
34
42
  }
@@ -39,5 +47,6 @@ module.exports = {
39
47
  snakeToCamel,
40
48
  snakeToPascal,
41
49
  snakeToTitle,
50
+ kebabToTitle,
42
51
  toClassName
43
52
  };