@restforgejs/platform 4.3.2 → 4.3.5
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/build-info.json +2 -2
- package/cli/consumer-deploy.js +1 -1
- package/cli/consumer.js +1 -1
- package/generators/cli/dashboard/create.js +0 -1
- package/generators/cli/endpoint/create.js +1 -1
- package/generators/lib/generators/model-generator.js +1 -1
- package/generators/lib/payload/endpoint-schema-validator.js +181 -171
- package/generators/lib/payload/payload-runner.js +1218 -1218
- package/generators/lib/payload/schema-diff.js +460 -277
- package/generators/lib/templates/dashboard-catalog.js +1 -1
- package/generators/lib/templates/db-connection-env.js +1 -1
- package/generators/lib/templates/dbschema-catalog.js +1 -1
- package/generators/lib/templates/field-validation-catalog.js +1 -1
- package/generators/lib/templates/mysql-template.js +1 -1
- package/generators/lib/templates/oracle-template.js +1 -1
- package/generators/lib/templates/postgres-template.js +1 -1
- package/generators/lib/templates/query-declarative-catalog.js +1 -1
- package/generators/lib/templates/sqlite-template.js +1 -1
- package/generators/lib/utils/conflict-checker.js +9 -57
- package/generators/lib/utils/database-introspector.js +84 -0
- package/generators/lib/utils/file-utils.js +0 -159
- package/generators/lib/utils/payload-processor.js +21 -112
- package/integrity-manifest.json +18 -18
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +1 -1
- package/scripts/verify-integrity.js +1 -1
- package/server.js +1 -1
- package/src/components/handlers/adjust_handler.js +1 -1
- package/src/components/handlers/audit_handler.js +1 -1
- package/src/components/handlers/delete_handler.js +1 -1
- package/src/components/handlers/export_handler.js +1 -1
- package/src/components/handlers/import_handler.js +1 -1
- package/src/components/handlers/insert_handler.js +1 -1
- package/src/components/handlers/update_handler.js +1 -1
- package/src/components/handlers/upload_handler.js +1 -1
- package/src/components/handlers/workflow_handler.js +1 -1
- package/src/components/integrations/webhook.js +1 -1
- package/src/consumers/baseConsumer.js +1 -1
- package/src/consumers/declarativeMapper.js +1 -1
- package/src/consumers/handlers/apiHandler.js +1 -1
- package/src/consumers/handlers/consoleHandler.js +1 -1
- package/src/consumers/handlers/databaseHandler.js +1 -1
- package/src/consumers/handlers/index.js +1 -1
- package/src/consumers/handlers/kafkaHandler.js +1 -1
- package/src/consumers/index.js +1 -1
- package/src/consumers/messageTransformer.js +1 -1
- package/src/consumers/validator.js +1 -1
- package/src/core/db/dialect/base-dialect.js +1 -1
- package/src/core/db/dialect/index.js +1 -1
- package/src/core/db/dialect/mysql-dialect.js +1 -1
- package/src/core/db/dialect/oracle-dialect.js +1 -1
- package/src/core/db/dialect/postgres-dialect.js +1 -1
- package/src/core/db/dialect/sqlite-dialect.js +1 -1
- package/src/core/db/flatten-helper.js +1 -1
- package/src/core/db/query-builder-error.js +1 -1
- package/src/core/db/query-builder.js +1 -1
- package/src/core/db/relation-helper.js +1 -1
- package/src/core/handlers/delete_handler.js +1 -1
- package/src/core/handlers/insert_handler.js +1 -1
- package/src/core/handlers/update_handler.js +1 -1
- package/src/core/models/base-model.js +1 -1
- package/src/core/utils/cache-manager.js +1 -1
- package/src/core/utils/component-engine.js +1 -1
- package/src/core/utils/context-builder.js +1 -1
- package/src/core/utils/datetime-formatter.js +1 -1
- package/src/core/utils/datetime-parser.js +1 -1
- package/src/core/utils/db.js +1 -1
- package/src/core/utils/logger.js +1 -1
- package/src/core/utils/payload-loader.js +1 -1
- package/src/core/utils/security-checks.js +1 -1
- package/src/middleware/body-options.js +1 -1
- package/src/middleware/cors.js +1 -1
- package/src/middleware/idempotency.js +1 -1
- package/src/middleware/rate-limiter.js +1 -1
- package/src/middleware/request-logger.js +1 -1
- package/src/middleware/security-headers.js +1 -1
- package/src/models/base-model-mysql.js +1 -1
- package/src/models/base-model-oracle.js +1 -1
- package/src/models/base-model-sqlite.js +1 -1
- package/src/models/base-model.js +1 -1
- package/src/pro/caching/redis-client.js +1 -1
- package/src/pro/caching/redis-helper.js +1 -1
- package/src/pro/consumers/baseConsumer.js +1 -1
- package/src/pro/consumers/declarativeMapper.js +1 -1
- package/src/pro/consumers/handlers/apiHandler.js +1 -1
- package/src/pro/consumers/handlers/consoleHandler.js +1 -1
- package/src/pro/consumers/handlers/databaseHandler.js +1 -1
- package/src/pro/consumers/handlers/index.js +1 -1
- package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
- package/src/pro/consumers/index.js +1 -1
- package/src/pro/consumers/messageTransformer.js +1 -1
- package/src/pro/consumers/validator.js +1 -1
- package/src/pro/database/base-model-mysql.js +1 -1
- package/src/pro/database/base-model-oracle.js +1 -1
- package/src/pro/database/base-model-sqlite.js +1 -1
- package/src/pro/database/db-mysql.js +1 -1
- package/src/pro/database/db-oracle.js +1 -1
- package/src/pro/database/db-sqlite.js +1 -1
- package/src/pro/excel/excel-generator.js +1 -1
- package/src/pro/excel/excel-parser.js +1 -1
- package/src/pro/excel/export-service.js +1 -1
- package/src/pro/excel/export_handler.js +1 -1
- package/src/pro/excel/import-service.js +1 -1
- package/src/pro/excel/import-validator.js +1 -1
- package/src/pro/excel/import_handler.js +1 -1
- package/src/pro/excel/upsert-builder.js +1 -1
- package/src/pro/idgen/idgen-routes.js +1 -1
- package/src/pro/integrations/lookup-resolver.js +1 -1
- package/src/pro/integrations/upload-handler-v2.js +1 -1
- package/src/pro/integrations/upload-handler.js +1 -1
- package/src/pro/integrations/webhook.js +1 -1
- package/src/pro/locking/lock-routes.js +1 -1
- package/src/pro/locking/resource-lock-manager.js +1 -1
- package/src/pro/messaging/kafkaConsumerService.js +1 -1
- package/src/pro/messaging/kafkaService.js +1 -1
- package/src/pro/messaging/messagehubService.js +1 -1
- package/src/pro/messaging/rabbitmqService.js +1 -1
- package/src/pro/scheduler/job-manager.js +1 -1
- package/src/pro/scheduler/job-routes.js +1 -1
- package/src/pro/scheduler/job-validator.js +1 -1
- package/src/pro/storage/base-storage-provider.js +1 -1
- package/src/pro/storage/file-metadata-helper.js +1 -1
- package/src/pro/storage/index.js +1 -1
- package/src/pro/storage/local-storage-provider.js +1 -1
- package/src/pro/storage/s3-storage-provider.js +1 -1
- package/src/pro/storage/upload-cleanup-job.js +1 -1
- package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
- package/src/pro/storage/upload-pending-tracker.js +1 -1
- package/src/pro/websocket/broadcast-helper.js +1 -1
- package/src/pro/websocket/index.js +1 -1
- package/src/pro/websocket/livesync-server.js +1 -1
- package/src/pro/websocket/ws-broadcaster.js +1 -1
- package/src/services/export-service.js +1 -1
- package/src/services/import-service.js +1 -1
- package/src/services/kafkaConsumerService.js +1 -1
- package/src/services/kafkaService.js +1 -1
- package/src/services/messagehubService.js +1 -1
- package/src/services/rabbitmqService.js +1 -1
- package/src/utils/cache-invalidation-registry.js +1 -1
- package/src/utils/cache-manager.js +1 -1
- package/src/utils/component-engine.js +1 -1
- package/src/utils/config-extractor.js +1 -1
- package/src/utils/consumerLogger.js +1 -1
- package/src/utils/context-builder.js +1 -1
- package/src/utils/dashboard-helpers.js +1 -1
- package/src/utils/dateHelper.js +1 -1
- package/src/utils/datetime-formatter.js +1 -1
- package/src/utils/datetime-parser.js +1 -1
- package/src/utils/db-bootstrap.js +1 -1
- package/src/utils/db-mysql.js +1 -1
- package/src/utils/db-oracle.js +1 -1
- package/src/utils/db-sqlite.js +1 -1
- package/src/utils/db.js +1 -1
- package/src/utils/demo-generator.js +1 -1
- package/src/utils/excel-generator.js +1 -1
- package/src/utils/excel-parser.js +1 -1
- package/src/utils/file-watcher.js +1 -1
- package/src/utils/id-generator.js +1 -1
- package/src/utils/idempotency-manager.js +1 -1
- package/src/utils/import-validator.js +1 -1
- package/src/utils/license-client.js +1 -1
- package/src/utils/lock-manager.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/lookup-resolver.js +1 -1
- package/src/utils/payload-loader.js +1 -1
- package/src/utils/processor-response.js +1 -1
- package/src/utils/rabbitmq.js +1 -1
- package/src/utils/redis-client.js +1 -1
- package/src/utils/redis-helper.js +1 -1
- package/src/utils/request-scope.js +1 -1
- package/src/utils/security-checks.js +1 -1
- package/src/utils/service-resolver.js +1 -1
- package/src/utils/shutdown-coordinator.js +1 -1
- package/src/utils/trusted-keys.js +1 -1
- package/src/utils/upload-handler.js +1 -1
- package/src/utils/upsert-builder.js +1 -1
- package/src/utils/workflow-hook-executor.js +1 -1
|
@@ -1,1218 +1,1218 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Payload Runner - Shared library untuk seluruh payload subcommand.
|
|
5
|
-
*
|
|
6
|
-
* Dipakai oleh 4 contract di generators/cli/payload/:
|
|
7
|
-
* - generate : non-interactive (--config dan --table wajib disertakan)
|
|
8
|
-
* - validate : mode --validate (read-only schema comparison)
|
|
9
|
-
* - diff : mode --diff (detailed schema comparison)
|
|
10
|
-
* - sync : mode --sync (update payload dari database, archive file lama)
|
|
11
|
-
*
|
|
12
|
-
* Inline migration v4.0.0: dipindah dari generators/create-payload.js
|
|
13
|
-
* ke generators/lib/payload/payload-runner.js. parseArguments()/showHelp()/main()
|
|
14
|
-
* dihapus karena cli-entry.js + contract handler menangani argv parsing.
|
|
15
|
-
* process.exit() di method run() dikonversi menjadi throw / return, sesuai
|
|
16
|
-
* konvensi inline migration (pattern guide: handler tidak boleh process.exit()).
|
|
17
|
-
*
|
|
18
|
-
* Exit code semantics:
|
|
19
|
-
* - run() return normal -> exit 0 via cli-entry.js
|
|
20
|
-
* - run() throw error -> exit 1 via cli-entry.js
|
|
21
|
-
* - Untuk mode validate/diff/sync, drift atau error sama-sama trigger throw
|
|
22
|
-
* (semantik "action required" untuk MCP/LSP).
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const fs = require('fs');
|
|
26
|
-
const path = require('path');
|
|
27
|
-
|
|
28
|
-
const { DatabaseIntrospector, loadDriver } = require('../utils/database-introspector');
|
|
29
|
-
const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
|
|
30
|
-
const {
|
|
31
|
-
DEFAULT_AUDIT_COLUMNS,
|
|
32
|
-
detectAuditAlignment
|
|
33
|
-
} = require('../utils/audit-columns');
|
|
34
|
-
const { compareSchemaStrict } = require('./schema-diff');
|
|
35
|
-
|
|
36
|
-
// Kolom audit yang di-handle otomatis oleh RESTForge runtime (base-model).
|
|
37
|
-
// Konstanta dipakai dari shared util agar source-of-truth tunggal lintas
|
|
38
|
-
// modul (sync, validate, endpoint create). Kolom ini di-exclude dari payload
|
|
39
|
-
// hasil generate karena nilainya selalu di-set runtime saat create/update.
|
|
40
|
-
//
|
|
41
|
-
// Bila tabel tidak memiliki satupun kolom dari list ini, generator otomatis
|
|
42
|
-
// menyertakan "auditColumns": false di payload supaya template generator
|
|
43
|
-
// men-set this.auditColumns = null di model. Tanpa flag ini, runtime akan
|
|
44
|
-
// mencoba inject kolom audit ke INSERT dan menyebabkan error 500.
|
|
45
|
-
|
|
46
|
-
// Deteksi environment - Bun compiled binary atau Node.js/Bun script
|
|
47
|
-
const isBun = typeof Bun !== 'undefined';
|
|
48
|
-
|
|
49
|
-
// Detection logic untuk compiled binary:
|
|
50
|
-
// 1. Cek apakah argv[1] mengandung $bunfs (Bun virtual filesystem)
|
|
51
|
-
// 2. Cek apakah execPath bukan path ke bun binary standar
|
|
52
|
-
// 3. Cek apakah nama executable mengandung 'restforge'
|
|
53
|
-
function detectBunCompiled() {
|
|
54
|
-
if (!isBun) return false;
|
|
55
|
-
|
|
56
|
-
const hasVirtualFS = process.argv[1] && process.argv[1].includes('$bunfs');
|
|
57
|
-
const execPathLower = process.execPath.toLowerCase();
|
|
58
|
-
const isBunStandardPath = execPathLower.includes('bun') &&
|
|
59
|
-
(execPathLower.endsWith('bun') || execPathLower.endsWith('bun.exe'));
|
|
60
|
-
const isRestforgeBinary = execPathLower.includes('restforge');
|
|
61
|
-
|
|
62
|
-
return hasVirtualFS || isRestforgeBinary || !isBunStandardPath;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const isBunCompiled = detectBunCompiled();
|
|
66
|
-
const isCompiledBinary = isBunCompiled;
|
|
67
|
-
const isScript = !isCompiledBinary;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Fungsi untuk mendapatkan working directory yang benar.
|
|
71
|
-
* Per pattern inline migration: panggil di handler-level (atau constructor),
|
|
72
|
-
* BUKAN di module-level. Sebab unit test melakukan chdir ke temp dir setelah
|
|
73
|
-
* module sudah di-load, sehingga snapshot module-level tidak ter-recognize.
|
|
74
|
-
* @returns {string} Working directory path
|
|
75
|
-
*/
|
|
76
|
-
function getWorkingDirectory() {
|
|
77
|
-
if (isCompiledBinary) {
|
|
78
|
-
const exeDir = path.dirname(process.execPath);
|
|
79
|
-
// Jika binary berada di node_modules (dijalankan via npx/npm),
|
|
80
|
-
// gunakan process.cwd() agar resolve ke project directory user
|
|
81
|
-
if (exeDir.includes('node_modules')) {
|
|
82
|
-
return process.cwd();
|
|
83
|
-
}
|
|
84
|
-
// Standalone binary: gunakan directory dimana binary berada
|
|
85
|
-
return exeDir;
|
|
86
|
-
} else {
|
|
87
|
-
return process.cwd();
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Suppress experimental warnings untuk compiled binary environment
|
|
92
|
-
if (isCompiledBinary) {
|
|
93
|
-
process.removeAllListeners('warning');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Class untuk Payload Generator (non-interactive only)
|
|
99
|
-
*/
|
|
100
|
-
class PayloadGenerator {
|
|
101
|
-
constructor() {
|
|
102
|
-
this.workingDir = getWorkingDirectory();
|
|
103
|
-
this.db = new DatabaseIntrospector();
|
|
104
|
-
this.outputDir = path.join(this.workingDir, 'payload');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Ensure output directory exists
|
|
109
|
-
*/
|
|
110
|
-
ensureOutputDir() {
|
|
111
|
-
if (!fs.existsSync(this.outputDir)) {
|
|
112
|
-
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
113
|
-
console.log(`Output directory created: ${this.outputDir}`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Generate dateTimeFields config from column types
|
|
119
|
-
* @param {Object} columnTypes - Map of column_name -> data_type
|
|
120
|
-
* @param {Array} fieldNames - Array of field names
|
|
121
|
-
* @returns {Object} dateTimeFields configuration
|
|
122
|
-
*/
|
|
123
|
-
generateDateTimeFields(columnTypes, fieldNames) {
|
|
124
|
-
const dateTimeFields = {};
|
|
125
|
-
|
|
126
|
-
for (const fieldName of fieldNames) {
|
|
127
|
-
const dataType = (columnTypes[fieldName] || '').toLowerCase();
|
|
128
|
-
|
|
129
|
-
if (dataType === 'date') {
|
|
130
|
-
dateTimeFields[fieldName] = {
|
|
131
|
-
type: 'date',
|
|
132
|
-
format: 'dd/MM/yyyy'
|
|
133
|
-
};
|
|
134
|
-
} else if (['timestamp', 'timestamp without time zone', 'timestamp with time zone'].includes(dataType)
|
|
135
|
-
|| dataType.startsWith('timestamp')) {
|
|
136
|
-
dateTimeFields[fieldName] = {
|
|
137
|
-
type: 'timestamp',
|
|
138
|
-
format: 'dd/MM/yyyy HH:mm'
|
|
139
|
-
};
|
|
140
|
-
} else if (['time', 'time without time zone', 'time with time zone'].includes(dataType)) {
|
|
141
|
-
dateTimeFields[fieldName] = {
|
|
142
|
-
type: 'time',
|
|
143
|
-
format: 'HH:mm'
|
|
144
|
-
};
|
|
145
|
-
} else if (dataType === 'datetime') {
|
|
146
|
-
// MySQL datetime type
|
|
147
|
-
dateTimeFields[fieldName] = {
|
|
148
|
-
type: 'timestamp',
|
|
149
|
-
format: 'dd/MM/yyyy HH:mm'
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return dateTimeFields;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Generate fieldValidation array from detailed column info
|
|
159
|
-
* Only includes "special" fields: primary key (UUID/auto-generate), numeric, boolean, date/time
|
|
160
|
-
* Regular string fields are excluded (treated as default)
|
|
161
|
-
* @param {Array} detailedColumns - Array from getDetailedColumnInfo()
|
|
162
|
-
* @param {Array} fieldNames - Array of selected field names
|
|
163
|
-
* @param {string} primaryKey - Primary key field name
|
|
164
|
-
* @returns {Array} fieldValidation array
|
|
165
|
-
*/
|
|
166
|
-
generateFieldValidation(detailedColumns, fieldNames, primaryKey) {
|
|
167
|
-
const fieldValidation = [];
|
|
168
|
-
|
|
169
|
-
// Build lookup map from detailed columns
|
|
170
|
-
const columnMap = {};
|
|
171
|
-
for (const col of detailedColumns) {
|
|
172
|
-
columnMap[col.column_name] = col;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
for (const fieldName of fieldNames) {
|
|
176
|
-
const col = columnMap[fieldName];
|
|
177
|
-
if (!col) continue;
|
|
178
|
-
|
|
179
|
-
const dataType = (col.data_type || '').toLowerCase();
|
|
180
|
-
const udtName = (col.udt_name || '').toLowerCase();
|
|
181
|
-
const columnDefault = (col.column_default || '').toLowerCase();
|
|
182
|
-
const isPrimaryKey = fieldName === primaryKey;
|
|
183
|
-
|
|
184
|
-
// --- Primary key detection ---
|
|
185
|
-
// Native UUID type (PostgreSQL `uuid`) -> type: 'uuid' + primaryKey + autoGenerate.
|
|
186
|
-
//
|
|
187
|
-
// Varchar/text PK -> type: 'string' + primaryKey + autoGenerate + required.
|
|
188
|
-
// Berlaku baik untuk varchar PK dengan DEFAULT (PostgreSQL uuid_generate_v4,
|
|
189
|
-
// Oracle SYS_GUID) maupun tanpa DEFAULT (MySQL dengan BEFORE INSERT trigger
|
|
190
|
-
// UUID(), atau PK yang di-handle runtime). Output identik lintas database
|
|
191
|
-
// karena base-model runtime selalu auto-generate UUID via uuidv7() untuk PK
|
|
192
|
-
// kosong, independen dari DEFAULT/trigger database. Konvensi project:
|
|
193
|
-
// PostgreSQL: VARCHAR(70) PRIMARY KEY DEFAULT uuid_generate_v4()::VARCHAR
|
|
194
|
-
// Oracle: VARCHAR2(70) DEFAULT SYS_GUID() PRIMARY KEY
|
|
195
|
-
// MySQL: VARCHAR(70) PRIMARY KEY (UUID via trigger)
|
|
196
|
-
const isUuidNativeType = dataType === 'uuid' || udtName === 'uuid';
|
|
197
|
-
const isVarcharLikePk = isPrimaryKey
|
|
198
|
-
&& ['character varying', 'varchar', 'text'].includes(dataType)
|
|
199
|
-
&& !columnDefault.includes('nextval');
|
|
200
|
-
|
|
201
|
-
if (isPrimaryKey && isUuidNativeType) {
|
|
202
|
-
fieldValidation.push({
|
|
203
|
-
name: fieldName,
|
|
204
|
-
type: 'uuid',
|
|
205
|
-
constraints: {
|
|
206
|
-
primaryKey: true,
|
|
207
|
-
autoGenerate: true
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (isVarcharLikePk) {
|
|
214
|
-
fieldValidation.push({
|
|
215
|
-
name: fieldName,
|
|
216
|
-
type: 'string',
|
|
217
|
-
constraints: {
|
|
218
|
-
primaryKey: true,
|
|
219
|
-
autoGenerate: true,
|
|
220
|
-
required: true
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// --- MySQL auto_increment primary key: SKIP ---
|
|
227
|
-
const extraInfo = (col.extra || '').toLowerCase();
|
|
228
|
-
if (isPrimaryKey && extraInfo.includes('auto_increment')) {
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// --- Integer types ---
|
|
233
|
-
// PostgreSQL: integer, bigint, smallint, serial, bigserial
|
|
234
|
-
// MySQL: int, tinyint, smallint, mediumint, bigint (normalized to lowercase by getDetailedColumnInfo)
|
|
235
|
-
// Oracle: already normalized to 'integer' by getDetailedColumnInfo when data_scale=0
|
|
236
|
-
if (['integer', 'bigint', 'smallint', 'serial', 'bigserial', 'smallserial',
|
|
237
|
-
'int', 'tinyint', 'mediumint'].includes(dataType) ||
|
|
238
|
-
['int4', 'int8', 'int2', 'serial4', 'serial8', 'serial2'].includes(udtName)) {
|
|
239
|
-
|
|
240
|
-
// Skip serial/auto-increment primary key
|
|
241
|
-
if (isPrimaryKey && (dataType.includes('serial') || columnDefault.includes('nextval'))) {
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Skip MySQL tinyint(1) — handled as boolean below
|
|
246
|
-
// (MySQL tinyint(1) is commonly used for boolean, but data_type from information_schema is 'tinyint')
|
|
247
|
-
// We'll check boolean pattern below instead
|
|
248
|
-
|
|
249
|
-
const entry = {
|
|
250
|
-
name: fieldName,
|
|
251
|
-
type: 'integer',
|
|
252
|
-
constraints: {}
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
// Default value
|
|
256
|
-
if (col.column_default !== null && !(col.column_default + '').includes('nextval')) {
|
|
257
|
-
const defaultVal = parseInt(col.column_default, 10);
|
|
258
|
-
if (!isNaN(defaultVal)) {
|
|
259
|
-
entry.constraints.default = defaultVal;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
fieldValidation.push(entry);
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// --- Numeric/Decimal types ---
|
|
268
|
-
// PostgreSQL: numeric, decimal, real, double precision
|
|
269
|
-
// MySQL: decimal, float, double
|
|
270
|
-
// Oracle: already normalized to 'numeric' by getDetailedColumnInfo when data_scale>0
|
|
271
|
-
if (['numeric', 'decimal', 'real', 'double precision', 'float', 'double'].includes(dataType) ||
|
|
272
|
-
['numeric', 'float4', 'float8'].includes(udtName)) {
|
|
273
|
-
|
|
274
|
-
const entry = {
|
|
275
|
-
name: fieldName,
|
|
276
|
-
type: 'number',
|
|
277
|
-
constraints: {}
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
// Precision from database
|
|
281
|
-
if (col.numeric_precision !== null && col.numeric_scale !== null && col.numeric_scale > 0) {
|
|
282
|
-
entry.constraints.precision = col.numeric_scale;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Default value
|
|
286
|
-
if (col.column_default !== null) {
|
|
287
|
-
const defaultVal = parseFloat(col.column_default);
|
|
288
|
-
if (!isNaN(defaultVal)) {
|
|
289
|
-
entry.constraints.default = defaultVal;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
fieldValidation.push(entry);
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// --- Boolean type ---
|
|
298
|
-
// PostgreSQL: boolean / bool
|
|
299
|
-
// MySQL: tinyint(1) — NOT detected here, MySQL uses varchar with 'true'/'false' in RESTForge
|
|
300
|
-
// Oracle: uses varchar2 with CHECK constraint for 'true'/'false' in RESTForge
|
|
301
|
-
if (dataType === 'boolean' || udtName === 'bool') {
|
|
302
|
-
const entry = {
|
|
303
|
-
name: fieldName,
|
|
304
|
-
type: 'boolean',
|
|
305
|
-
constraints: {}
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
// Default value
|
|
309
|
-
if (col.column_default !== null) {
|
|
310
|
-
if (columnDefault === 'true') {
|
|
311
|
-
entry.constraints.default = true;
|
|
312
|
-
} else if (columnDefault === 'false') {
|
|
313
|
-
entry.constraints.default = false;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
fieldValidation.push(entry);
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// --- Date type ---
|
|
322
|
-
if (dataType === 'date') {
|
|
323
|
-
const entry = {
|
|
324
|
-
name: fieldName,
|
|
325
|
-
type: 'date',
|
|
326
|
-
constraints: {
|
|
327
|
-
format: 'dd/MM/yyyy'
|
|
328
|
-
}
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
fieldValidation.push(entry);
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// --- Timestamp / Datetime types ---
|
|
336
|
-
// PostgreSQL: timestamp, timestamp without time zone, timestamp with time zone
|
|
337
|
-
// MySQL: timestamp, datetime
|
|
338
|
-
// Oracle: already normalized to 'timestamp' by getDetailedColumnInfo
|
|
339
|
-
if (['timestamp', 'timestamp without time zone', 'timestamp with time zone', 'datetime'].includes(dataType)
|
|
340
|
-
|| dataType.startsWith('timestamp')) {
|
|
341
|
-
const entry = {
|
|
342
|
-
name: fieldName,
|
|
343
|
-
type: 'datetime',
|
|
344
|
-
constraints: {
|
|
345
|
-
format: 'dd/MM/yyyy HH:mm:ss'
|
|
346
|
-
}
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
// Detect auto-generate: now(), CURRENT_TIMESTAMP, SYSTIMESTAMP
|
|
350
|
-
if (columnDefault.includes('now') || columnDefault.includes('current_timestamp')
|
|
351
|
-
|| columnDefault.includes('systimestamp')) {
|
|
352
|
-
entry.constraints.autoGenerate = true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
fieldValidation.push(entry);
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// --- Time types ---
|
|
360
|
-
if (['time', 'time without time zone', 'time with time zone'].includes(dataType)) {
|
|
361
|
-
const entry = {
|
|
362
|
-
name: fieldName,
|
|
363
|
-
type: 'time',
|
|
364
|
-
constraints: {
|
|
365
|
-
format: 'HH:mm:ss'
|
|
366
|
-
}
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
fieldValidation.push(entry);
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// --- String fields: SKIP (default behavior, tidak perlu di fieldValidation) ---
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return fieldValidation;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Main run method
|
|
382
|
-
* @param {Object} args - Command line arguments
|
|
383
|
-
*/
|
|
384
|
-
async run(args) {
|
|
385
|
-
try {
|
|
386
|
-
// MANDATORY: Database config harus disediakan (eksplisit via --config atau via default)
|
|
387
|
-
const resolved = resolveConfig(args.config, this.workingDir);
|
|
388
|
-
if (!resolved) {
|
|
389
|
-
console.error('='.repeat(60));
|
|
390
|
-
console.error('ERROR: Database configuration required');
|
|
391
|
-
console.error('='.repeat(60));
|
|
392
|
-
console.error();
|
|
393
|
-
console.error('Usage:');
|
|
394
|
-
console.error(' npx restforge payload generate --config=<filename.env> --table=<table-name>');
|
|
395
|
-
console.error();
|
|
396
|
-
console.error('Example:');
|
|
397
|
-
console.error(' npx restforge payload generate --config=mini-inventory.env --table=users');
|
|
398
|
-
console.error(' npx restforge payload generate --config=database.env --table=orders');
|
|
399
|
-
console.error();
|
|
400
|
-
console.error('The config file must be in the config/ directory or current directory.');
|
|
401
|
-
console.error();
|
|
402
|
-
console.error("Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs.");
|
|
403
|
-
console.error();
|
|
404
|
-
throw new Error('Database configuration required');
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (resolved.source === 'default') {
|
|
408
|
-
printDefaultConfigWarning(resolved.defaultName);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Load database config
|
|
412
|
-
console.log('='.repeat(60));
|
|
413
|
-
console.log('Loading Database Configuration');
|
|
414
|
-
console.log('='.repeat(60));
|
|
415
|
-
console.log();
|
|
416
|
-
|
|
417
|
-
const configPath = resolved.path;
|
|
418
|
-
|
|
419
|
-
// Load config - throw jika gagal
|
|
420
|
-
if (!this.db.loadConfig(configPath)) {
|
|
421
|
-
console.error();
|
|
422
|
-
console.error('FATAL: Failed to load database configuration');
|
|
423
|
-
console.error(`Config file: ${configPath}`);
|
|
424
|
-
console.error();
|
|
425
|
-
throw new Error('Failed to load database configuration');
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Connect to database - throw jika gagal
|
|
429
|
-
const connected = await this.db.connect();
|
|
430
|
-
if (!connected) {
|
|
431
|
-
console.error();
|
|
432
|
-
console.error('FATAL: Failed to connect to database');
|
|
433
|
-
console.error('Please check your database configuration and connection.');
|
|
434
|
-
console.error();
|
|
435
|
-
throw new Error('Failed to connect to database');
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
console.log();
|
|
439
|
-
console.log('Database connected successfully');
|
|
440
|
-
console.log();
|
|
441
|
-
|
|
442
|
-
// Set output directory if provided
|
|
443
|
-
if (args.output) {
|
|
444
|
-
this.outputDir = path.isAbsolute(args.output)
|
|
445
|
-
? args.output
|
|
446
|
-
: path.join(this.workingDir, args.output);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Schema validation modes: --validate, --diff, --sync
|
|
450
|
-
if (args.validate || args.diff || args.sync) {
|
|
451
|
-
const validator = new SchemaValidator(this.db, this.outputDir);
|
|
452
|
-
const targetTable = args.table || null;
|
|
453
|
-
|
|
454
|
-
let result;
|
|
455
|
-
if (args.sync) {
|
|
456
|
-
result = await validator.runSync(targetTable, this);
|
|
457
|
-
} else if (args.diff) {
|
|
458
|
-
result = await validator.runDiff(targetTable);
|
|
459
|
-
} else {
|
|
460
|
-
result = await validator.runValidate(targetTable);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Cleanup
|
|
464
|
-
await this.db.close();
|
|
465
|
-
|
|
466
|
-
// Drift, error, atau target tidak ditemukan -> throw (exit 1 via cli-entry.js).
|
|
467
|
-
// Semantik "action required" untuk MCP/LSP integration.
|
|
468
|
-
// Marked silent karena verdict + summary sudah di-print oleh runValidate/
|
|
469
|
-
// runDiff/runSync; pesan "Error: Payload schema action required: ..."
|
|
470
|
-
// hanya menambah noise di stderr untuk human user.
|
|
471
|
-
const hasDrift = (result?.drift ?? 0) > 0;
|
|
472
|
-
const hasError = (result?.error ?? 0) > 0;
|
|
473
|
-
const targetMissing = targetTable && (result?.total ?? 0) === 0;
|
|
474
|
-
|
|
475
|
-
if (hasDrift || hasError || targetMissing) {
|
|
476
|
-
const reasons = [];
|
|
477
|
-
if (hasDrift) reasons.push(`drift=${result.drift}`);
|
|
478
|
-
if (hasError) reasons.push(`error=${result.error}`);
|
|
479
|
-
if (targetMissing) reasons.push(`target '${targetTable}' not found`);
|
|
480
|
-
const err = new Error(`Payload schema action required: ${reasons.join(', ')}`);
|
|
481
|
-
err.silent = true;
|
|
482
|
-
err.exitCode = 1;
|
|
483
|
-
throw err;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Default mode generate: --table wajib (interactive mode sudah dihapus)
|
|
490
|
-
await this.runNonInteractive(args);
|
|
491
|
-
|
|
492
|
-
await this.db.close();
|
|
493
|
-
|
|
494
|
-
console.log();
|
|
495
|
-
console.log('Done.');
|
|
496
|
-
console.log();
|
|
497
|
-
} catch (error) {
|
|
498
|
-
// Cleanup resources lalu re-throw agar cli-entry.js handle exit code.
|
|
499
|
-
try { await this.db.close(); } catch (_e) { /* ignore close errors */ }
|
|
500
|
-
throw error;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Run in non-interactive mode
|
|
506
|
-
* @param {Object} args - Command line arguments
|
|
507
|
-
*/
|
|
508
|
-
async runNonInteractive(args) {
|
|
509
|
-
console.log();
|
|
510
|
-
console.log('Running in non-interactive mode...');
|
|
511
|
-
console.log();
|
|
512
|
-
|
|
513
|
-
// Pre-check: verify table exists before attempting introspection
|
|
514
|
-
if (this.db.pool) {
|
|
515
|
-
const exists = await this.db.tableExists(args.table);
|
|
516
|
-
if (!exists) {
|
|
517
|
-
console.error(`Error: Table '${args.table}' does not exist.`);
|
|
518
|
-
await this.db.close();
|
|
519
|
-
throw new Error(`Table '${args.table}' does not exist`);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const payloadData = {
|
|
524
|
-
tableName: args.table,
|
|
525
|
-
primaryKey: args.primaryKey || null
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
// Get primary key from database if not provided
|
|
529
|
-
if (!payloadData.primaryKey && this.db.pool) {
|
|
530
|
-
payloadData.primaryKey = await this.db.getPrimaryKey(args.table);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (!payloadData.primaryKey) {
|
|
534
|
-
// Guess from table name
|
|
535
|
-
const tableNamePart = args.table.split('.').pop();
|
|
536
|
-
payloadData.primaryKey = tableNamePart.replace(/s$/, '') + '_id';
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
console.log(`Table: ${payloadData.tableName}`);
|
|
540
|
-
console.log(`Primary Key: ${payloadData.primaryKey}`);
|
|
541
|
-
|
|
542
|
-
// Get fields from database
|
|
543
|
-
if (this.db.pool) {
|
|
544
|
-
const columns = await this.db.getColumns(args.table);
|
|
545
|
-
if (columns.length > 0) {
|
|
546
|
-
// Filter kolom audit yang di-handle otomatis oleh runtime.
|
|
547
|
-
const excludedPresent = columns.filter(col => DEFAULT_AUDIT_COLUMNS.includes(col));
|
|
548
|
-
payloadData.fieldName = columns.filter(col => !DEFAULT_AUDIT_COLUMNS.includes(col));
|
|
549
|
-
if (excludedPresent.length > 0) {
|
|
550
|
-
console.log(`Auto-managed columns excluded: ${excludedPresent.join(', ')}`);
|
|
551
|
-
} else {
|
|
552
|
-
// Tabel tidak punya satupun kolom audit standar: emit auditColumns: false
|
|
553
|
-
// supaya generator template men-set this.auditColumns = null di model.
|
|
554
|
-
payloadData.auditColumns = false;
|
|
555
|
-
console.log('No audit columns detected in table: setting auditColumns: false');
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
if (!payloadData.fieldName || payloadData.fieldName.length === 0) {
|
|
561
|
-
console.error(`Error: Cannot create payload — no fields found for table '${args.table}'.`);
|
|
562
|
-
await this.db.close();
|
|
563
|
-
throw new Error(`No fields found for table '${args.table}'`);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
console.log(`Fields: ${payloadData.fieldName.length}`);
|
|
567
|
-
|
|
568
|
-
// Generate query sebagai file external (file:query/...)
|
|
569
|
-
const baseFilename = args.table.replace(/[._]/g, '-');
|
|
570
|
-
const sqlFilename = `${baseFilename}-datatables.sql`;
|
|
571
|
-
const sqlLines = payloadData.fieldName.map((field, idx) => {
|
|
572
|
-
const prefix = idx === 0 ? 'SELECT ' : ' ';
|
|
573
|
-
const suffix = idx === payloadData.fieldName.length - 1 ? '' : ',';
|
|
574
|
-
return `${prefix}a.${field}${suffix}`;
|
|
575
|
-
});
|
|
576
|
-
const sqlContent = `${sqlLines.join('\n')}\nFROM ${payloadData.tableName} a\n`;
|
|
577
|
-
payloadData.datatablesQuery = `file:query/${sqlFilename}`;
|
|
578
|
-
|
|
579
|
-
// Generate searchable columns
|
|
580
|
-
const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
|
|
581
|
-
payloadData.datatablesWhere = payloadData.fieldName.filter(field => {
|
|
582
|
-
const fieldLower = field.toLowerCase();
|
|
583
|
-
return !excludePatterns.some(pattern => fieldLower.includes(pattern));
|
|
584
|
-
});
|
|
585
|
-
payloadData.datatablesWhere.push('all');
|
|
586
|
-
|
|
587
|
-
// Enable all actions
|
|
588
|
-
payloadData.action = {
|
|
589
|
-
datatables: true,
|
|
590
|
-
create: true,
|
|
591
|
-
update: true,
|
|
592
|
-
delete: true,
|
|
593
|
-
first: true,
|
|
594
|
-
lookup: true,
|
|
595
|
-
read: true
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
// Generate dateTimeFields and fieldValidation
|
|
599
|
-
if (this.db.pool) {
|
|
600
|
-
const columnTypes = await this.db.getColumnTypes(args.table);
|
|
601
|
-
const dateTimeFields = this.generateDateTimeFields(columnTypes, payloadData.fieldName);
|
|
602
|
-
if (Object.keys(dateTimeFields).length > 0) {
|
|
603
|
-
payloadData.dateTimeFields = dateTimeFields;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Generate fieldValidation for special fields
|
|
607
|
-
const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
|
|
608
|
-
const fieldValidation = this.generateFieldValidation(detailedColumns, payloadData.fieldName, payloadData.primaryKey);
|
|
609
|
-
if (fieldValidation.length > 0) {
|
|
610
|
-
payloadData.fieldValidation = fieldValidation;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Save payload
|
|
615
|
-
this.ensureOutputDir();
|
|
616
|
-
const filename = baseFilename + '.json';
|
|
617
|
-
const outputPath = path.join(this.outputDir, filename);
|
|
618
|
-
|
|
619
|
-
// Tulis file SQL external ke payload/query/<table>-datatables.sql
|
|
620
|
-
const queryDir = path.join(this.outputDir, 'query');
|
|
621
|
-
if (!fs.existsSync(queryDir)) {
|
|
622
|
-
fs.mkdirSync(queryDir, { recursive: true });
|
|
623
|
-
}
|
|
624
|
-
const sqlOutputPath = path.join(queryDir, sqlFilename);
|
|
625
|
-
fs.writeFileSync(sqlOutputPath, sqlContent, 'utf8');
|
|
626
|
-
|
|
627
|
-
fs.writeFileSync(outputPath, JSON.stringify(payloadData, null, 4), 'utf8');
|
|
628
|
-
console.log();
|
|
629
|
-
console.log(`Payload saved: ${outputPath}`);
|
|
630
|
-
console.log(`Query saved: ${sqlOutputPath}`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// ============================================================================
|
|
635
|
-
// SCHEMA VALIDATOR - Validate & Diff payload vs database schema
|
|
636
|
-
// ============================================================================
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* Class untuk validasi payload JSON terhadap schema database aktual.
|
|
640
|
-
* Mendukung mode: validate, diff, dan sync.
|
|
641
|
-
*/
|
|
642
|
-
class SchemaValidator {
|
|
643
|
-
/**
|
|
644
|
-
* @param {DatabaseIntrospector} db - Instance DatabaseIntrospector yang sudah terkoneksi
|
|
645
|
-
* @param {string} outputDir - Direktori payload
|
|
646
|
-
*/
|
|
647
|
-
constructor(db, outputDir) {
|
|
648
|
-
this.db = db;
|
|
649
|
-
this.outputDir = outputDir;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Cari semua file payload JSON di outputDir.
|
|
654
|
-
* @returns {Array<{filePath: string, fileName: string, payload: Object}>}
|
|
655
|
-
*/
|
|
656
|
-
findPayloadFiles() {
|
|
657
|
-
if (!fs.existsSync(this.outputDir)) {
|
|
658
|
-
return [];
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const files = fs.readdirSync(this.outputDir)
|
|
662
|
-
.filter(f => f.endsWith('.json') && !f.includes('.archive.'));
|
|
663
|
-
|
|
664
|
-
const results = [];
|
|
665
|
-
for (const fileName of files) {
|
|
666
|
-
const filePath = path.join(this.outputDir, fileName);
|
|
667
|
-
try {
|
|
668
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
669
|
-
const payload = JSON.parse(content);
|
|
670
|
-
if (payload.tableName && payload.fieldName) {
|
|
671
|
-
results.push({ filePath, fileName, payload });
|
|
672
|
-
}
|
|
673
|
-
} catch (e) {
|
|
674
|
-
// Skip file yang bukan valid payload JSON
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
return results;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* Cari file payload untuk table tertentu.
|
|
682
|
-
* Pencocokan berdasarkan tableName di dalam payload atau nama file.
|
|
683
|
-
* @param {string} tableName - Nama table (misal: core.supplier atau supplier)
|
|
684
|
-
* @returns {Object|null} { filePath, fileName, payload } atau null
|
|
685
|
-
*/
|
|
686
|
-
findPayloadForTable(tableName) {
|
|
687
|
-
const allPayloads = this.findPayloadFiles();
|
|
688
|
-
|
|
689
|
-
// Exact match pada tableName
|
|
690
|
-
let match = allPayloads.find(p => p.payload.tableName === tableName);
|
|
691
|
-
if (match) return match;
|
|
692
|
-
|
|
693
|
-
// Match tanpa schema prefix
|
|
694
|
-
const tableOnly = tableName.includes('.') ? tableName.split('.').pop() : tableName;
|
|
695
|
-
match = allPayloads.find(p => {
|
|
696
|
-
const payloadTable = p.payload.tableName.includes('.')
|
|
697
|
-
? p.payload.tableName.split('.').pop()
|
|
698
|
-
: p.payload.tableName;
|
|
699
|
-
return payloadTable === tableOnly;
|
|
700
|
-
});
|
|
701
|
-
if (match) return match;
|
|
702
|
-
|
|
703
|
-
// Match berdasarkan filename pattern (kebab-case default + legacy snake_case)
|
|
704
|
-
const expectedFiles = [
|
|
705
|
-
tableName.replace(/[._]/g, '-') + '.json',
|
|
706
|
-
tableName.replace('.', '_') + '.json',
|
|
707
|
-
tableOnly.replace(/_/g, '-') + '.json',
|
|
708
|
-
tableOnly + '.json'
|
|
709
|
-
];
|
|
710
|
-
match = allPayloads.find(p => expectedFiles.includes(p.fileName));
|
|
711
|
-
return match || null;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Format hasil perbandingan ke output console.
|
|
716
|
-
* Konsisten dengan `endpoint create` (audit-column-aware).
|
|
717
|
-
*
|
|
718
|
-
* @param {Object} comparison - Hasil dari compareSchemaStrict()
|
|
719
|
-
* @param {string} fileName - Nama file payload
|
|
720
|
-
* @param {boolean} showDiffDetail - Tampilkan detail per-column drift
|
|
721
|
-
*/
|
|
722
|
-
printComparisonResult(comparison, fileName, showDiffDetail = false) {
|
|
723
|
-
// Pad status icon ke lebar tetap 7 char ([DRIFT]/[ERROR]) supaya kolom
|
|
724
|
-
// fileName tampil rata antar baris.
|
|
725
|
-
const statusIcon = comparison.status === 'ok' ? '[OK] '
|
|
726
|
-
: comparison.status === 'drift' ? '[DRIFT]'
|
|
727
|
-
: '[ERROR]';
|
|
728
|
-
|
|
729
|
-
console.log(` ${statusIcon} ${fileName} (${comparison.tableName})`);
|
|
730
|
-
|
|
731
|
-
if (comparison.status === 'error') {
|
|
732
|
-
console.log(` ${comparison.summary}`);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (comparison.status === 'ok') {
|
|
737
|
-
if (showDiffDetail) {
|
|
738
|
-
console.log(' Schema is in sync');
|
|
739
|
-
}
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Status: drift
|
|
744
|
-
console.log(` ${comparison.summary}`);
|
|
745
|
-
|
|
746
|
-
if (!showDiffDetail) return;
|
|
747
|
-
|
|
748
|
-
if (Array.isArray(comparison.removed)) {
|
|
749
|
-
for (const item of comparison.removed) {
|
|
750
|
-
console.log(` [-] ${item.column} (in payload, not in database)`);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
if (Array.isArray(comparison.typeChanges)) {
|
|
754
|
-
for (const item of comparison.typeChanges) {
|
|
755
|
-
console.log(` [~] ${item.column} (type: ${item.payloadType} -> ${item.databaseType})`);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
if (Array.isArray(comparison.added)) {
|
|
759
|
-
for (const item of comparison.added) {
|
|
760
|
-
const nullable = item.nullable ? ', nullable' : ', not null';
|
|
761
|
-
console.log(` [+] ${item.column} (${item.type}${nullable}) (in database, not in payload)`);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
if (Array.isArray(comparison.auditMissing) && comparison.auditMissing.length > 0) {
|
|
765
|
-
const cols = comparison.auditMissing.join(', ');
|
|
766
|
-
console.log(` [+] ${cols}`);
|
|
767
|
-
console.log(' (required by auditColumns=true, not in database)');
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Jalankan validasi untuk satu table atau semua payload.
|
|
773
|
-
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
774
|
-
* @returns {Promise<Object>} { total, ok, drift, error, results }
|
|
775
|
-
*/
|
|
776
|
-
async runValidate(tableName) {
|
|
777
|
-
console.log();
|
|
778
|
-
console.log('='.repeat(60));
|
|
779
|
-
console.log('SCHEMA VALIDATION - Payload vs Database');
|
|
780
|
-
console.log('='.repeat(60));
|
|
781
|
-
console.log();
|
|
782
|
-
|
|
783
|
-
let payloads;
|
|
784
|
-
if (tableName) {
|
|
785
|
-
const found = this.findPayloadForTable(tableName);
|
|
786
|
-
if (!found) {
|
|
787
|
-
console.log(` Payload file not found for table: ${tableName}`);
|
|
788
|
-
console.log(` Searched in: ${this.outputDir}`);
|
|
789
|
-
console.log();
|
|
790
|
-
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
791
|
-
}
|
|
792
|
-
payloads = [found];
|
|
793
|
-
} else {
|
|
794
|
-
payloads = this.findPayloadFiles();
|
|
795
|
-
if (payloads.length === 0) {
|
|
796
|
-
console.log(` No payload files found in: ${this.outputDir}`);
|
|
797
|
-
console.log();
|
|
798
|
-
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
console.log(` Payload directory : ${this.outputDir}`);
|
|
803
|
-
console.log(` Files to validate : ${payloads.length}`);
|
|
804
|
-
console.log();
|
|
805
|
-
console.log('-'.repeat(60));
|
|
806
|
-
console.log();
|
|
807
|
-
|
|
808
|
-
const results = [];
|
|
809
|
-
let ok = 0, drift = 0, error = 0;
|
|
810
|
-
|
|
811
|
-
for (const { fileName, payload } of payloads) {
|
|
812
|
-
const comparison = await compareSchemaStrict(payload, this.db);
|
|
813
|
-
results.push({ fileName, comparison });
|
|
814
|
-
this.printComparisonResult(comparison, fileName, false);
|
|
815
|
-
|
|
816
|
-
if (comparison.status === 'ok') ok++;
|
|
817
|
-
else if (comparison.status === 'drift') drift++;
|
|
818
|
-
else error++;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// Summary
|
|
822
|
-
console.log();
|
|
823
|
-
console.log('-'.repeat(60));
|
|
824
|
-
console.log();
|
|
825
|
-
console.log(' Summary:');
|
|
826
|
-
console.log(` Total : ${payloads.length} payload(s)`);
|
|
827
|
-
console.log(` OK : ${ok}`);
|
|
828
|
-
if (drift > 0) console.log(` Drift : ${drift}`);
|
|
829
|
-
if (error > 0) console.log(` Error : ${error}`);
|
|
830
|
-
console.log();
|
|
831
|
-
|
|
832
|
-
if (drift > 0 || error > 0) {
|
|
833
|
-
console.log(' Use --diff for detailed comparison.');
|
|
834
|
-
console.log(' Use --sync to update payload files automatically.');
|
|
835
|
-
console.log();
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
return { total: payloads.length, ok, drift, error, results };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Jalankan diff detail untuk satu table atau semua payload.
|
|
843
|
-
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
844
|
-
* @returns {Promise<Object>} { total, ok, drift, error, results }
|
|
845
|
-
*/
|
|
846
|
-
async runDiff(tableName) {
|
|
847
|
-
console.log();
|
|
848
|
-
console.log('='.repeat(60));
|
|
849
|
-
console.log('SCHEMA DIFF - Payload vs Database');
|
|
850
|
-
console.log('='.repeat(60));
|
|
851
|
-
console.log();
|
|
852
|
-
|
|
853
|
-
let payloads;
|
|
854
|
-
if (tableName) {
|
|
855
|
-
const found = this.findPayloadForTable(tableName);
|
|
856
|
-
if (!found) {
|
|
857
|
-
console.log(` Payload file not found for table: ${tableName}`);
|
|
858
|
-
console.log(` Searched in: ${this.outputDir}`);
|
|
859
|
-
console.log();
|
|
860
|
-
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
861
|
-
}
|
|
862
|
-
payloads = [found];
|
|
863
|
-
} else {
|
|
864
|
-
payloads = this.findPayloadFiles();
|
|
865
|
-
if (payloads.length === 0) {
|
|
866
|
-
console.log(` No payload files found in: ${this.outputDir}`);
|
|
867
|
-
console.log();
|
|
868
|
-
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
console.log(` Payload directory : ${this.outputDir}`);
|
|
873
|
-
console.log(` Files to compare : ${payloads.length}`);
|
|
874
|
-
console.log();
|
|
875
|
-
|
|
876
|
-
const results = [];
|
|
877
|
-
let ok = 0, drift = 0, error = 0;
|
|
878
|
-
|
|
879
|
-
for (const { fileName, payload } of payloads) {
|
|
880
|
-
console.log('-'.repeat(60));
|
|
881
|
-
console.log();
|
|
882
|
-
|
|
883
|
-
const comparison = await compareSchemaStrict(payload, this.db);
|
|
884
|
-
results.push({ fileName, comparison });
|
|
885
|
-
this.printComparisonResult(comparison, fileName, true);
|
|
886
|
-
console.log();
|
|
887
|
-
|
|
888
|
-
if (comparison.status === 'ok') ok++;
|
|
889
|
-
else if (comparison.status === 'drift') drift++;
|
|
890
|
-
else error++;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// Summary
|
|
894
|
-
console.log('-'.repeat(60));
|
|
895
|
-
console.log();
|
|
896
|
-
console.log(' Summary:');
|
|
897
|
-
console.log(` Total : ${payloads.length} payload(s)`);
|
|
898
|
-
console.log(` OK : ${ok}`);
|
|
899
|
-
if (drift > 0) console.log(` Drift : ${drift}`);
|
|
900
|
-
if (error > 0) console.log(` Error : ${error}`);
|
|
901
|
-
console.log();
|
|
902
|
-
|
|
903
|
-
if (drift > 0) {
|
|
904
|
-
console.log(' Use --sync to update payload files automatically.');
|
|
905
|
-
console.log();
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
return { total: payloads.length, ok, drift, error, results };
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
/**
|
|
912
|
-
* Tentukan nomor archive berikutnya untuk sebuah file.
|
|
913
|
-
* Scan file .archive.001, .archive.002, dst. dan return nomor berikutnya.
|
|
914
|
-
* @param {string} filePath - Path file payload asli
|
|
915
|
-
* @returns {number} Nomor archive berikutnya
|
|
916
|
-
*/
|
|
917
|
-
getNextArchiveNumber(filePath) {
|
|
918
|
-
const dir = path.dirname(filePath);
|
|
919
|
-
const baseName = path.basename(filePath);
|
|
920
|
-
const files = fs.readdirSync(dir);
|
|
921
|
-
let maxNumber = 0;
|
|
922
|
-
|
|
923
|
-
const pattern = new RegExp(`^${baseName.replace(/\./g, '\\.')}\\.archive\\.(\\d+)$`);
|
|
924
|
-
for (const file of files) {
|
|
925
|
-
const match = file.match(pattern);
|
|
926
|
-
if (match) {
|
|
927
|
-
const num = parseInt(match[1], 10);
|
|
928
|
-
if (num > maxNumber) maxNumber = num;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
return maxNumber + 1;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Archive file payload lama sebelum overwrite.
|
|
937
|
-
* Rename ke {filename}.archive.001, .002, dst.
|
|
938
|
-
* @param {string} filePath - Path file payload
|
|
939
|
-
* @returns {string} Path file archive yang dibuat
|
|
940
|
-
*/
|
|
941
|
-
archiveFile(filePath) {
|
|
942
|
-
const nextNum = this.getNextArchiveNumber(filePath);
|
|
943
|
-
const archiveName = `${filePath}.archive.${String(nextNum).padStart(3, '0')}`;
|
|
944
|
-
fs.renameSync(filePath, archiveName);
|
|
945
|
-
return archiveName;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* Jalankan sync: update payload yang drift dengan data dari database.
|
|
950
|
-
* File lama di-archive sebelum overwrite.
|
|
951
|
-
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
952
|
-
* @param {PayloadGenerator} generator - Instance PayloadGenerator untuk re-generate
|
|
953
|
-
* @returns {Promise<Object>} { total, synced, skipped, error }
|
|
954
|
-
*/
|
|
955
|
-
async runSync(tableName, generator) {
|
|
956
|
-
console.log();
|
|
957
|
-
console.log('='.repeat(60));
|
|
958
|
-
console.log('SCHEMA SYNC - Update Payload from Database');
|
|
959
|
-
console.log('='.repeat(60));
|
|
960
|
-
console.log();
|
|
961
|
-
|
|
962
|
-
let payloads;
|
|
963
|
-
if (tableName) {
|
|
964
|
-
const found = this.findPayloadForTable(tableName);
|
|
965
|
-
if (!found) {
|
|
966
|
-
console.log(` Payload file not found for table: ${tableName}`);
|
|
967
|
-
console.log(` Searched in: ${this.outputDir}`);
|
|
968
|
-
console.log();
|
|
969
|
-
return { total: 0, synced: 0, skipped: 0, error: 0 };
|
|
970
|
-
}
|
|
971
|
-
payloads = [found];
|
|
972
|
-
} else {
|
|
973
|
-
payloads = this.findPayloadFiles();
|
|
974
|
-
if (payloads.length === 0) {
|
|
975
|
-
console.log(` No payload files found in: ${this.outputDir}`);
|
|
976
|
-
console.log();
|
|
977
|
-
return { total: 0, synced: 0, skipped: 0, error: 0 };
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
console.log(` Payload directory : ${this.outputDir}`);
|
|
982
|
-
console.log(` Files to check : ${payloads.length}`);
|
|
983
|
-
console.log();
|
|
984
|
-
console.log('-'.repeat(60));
|
|
985
|
-
console.log();
|
|
986
|
-
|
|
987
|
-
let synced = 0, skipped = 0, errorCount = 0;
|
|
988
|
-
|
|
989
|
-
for (const { filePath, fileName, payload } of payloads) {
|
|
990
|
-
const comparison = await compareSchemaStrict(payload, this.db);
|
|
991
|
-
|
|
992
|
-
if (comparison.status === 'error') {
|
|
993
|
-
console.log(` [ERROR] ${fileName} - ${comparison.summary}`);
|
|
994
|
-
errorCount++;
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
if (comparison.status === 'ok') {
|
|
999
|
-
console.log(` [SKIP] ${fileName} - already in sync`);
|
|
1000
|
-
skipped++;
|
|
1001
|
-
continue;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// Status: drift (termasuk audit drift) — archive + regenerate.
|
|
1005
|
-
const archivePath = this.archiveFile(filePath);
|
|
1006
|
-
const archiveName = path.basename(archivePath);
|
|
1007
|
-
console.log(` [ARCHIVE] ${fileName} -> ${archiveName}`);
|
|
1008
|
-
|
|
1009
|
-
// Re-generate payload dari database. regeneratePayload menerapkan audit
|
|
1010
|
-
// columns resolution secara internal (set auditColumns: false bila DB
|
|
1011
|
-
// tidak punya kolom audit standar).
|
|
1012
|
-
try {
|
|
1013
|
-
const updatedPayload = await this.regeneratePayload(payload, comparison, { fileName });
|
|
1014
|
-
fs.writeFileSync(filePath, JSON.stringify(updatedPayload, null, 4), 'utf8');
|
|
1015
|
-
console.log(` [SYNCED] ${fileName} - ${comparison.summary}`);
|
|
1016
|
-
synced++;
|
|
1017
|
-
} catch (e) {
|
|
1018
|
-
// Restore dari archive jika gagal
|
|
1019
|
-
fs.renameSync(archivePath, filePath);
|
|
1020
|
-
console.log(` [ERROR] ${fileName} - sync failed: ${e.message}`);
|
|
1021
|
-
errorCount++;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// Summary
|
|
1026
|
-
console.log();
|
|
1027
|
-
console.log('-'.repeat(60));
|
|
1028
|
-
console.log();
|
|
1029
|
-
console.log(' Summary:');
|
|
1030
|
-
console.log(` Total : ${payloads.length} payload(s)`);
|
|
1031
|
-
console.log(` Synced : ${synced}`);
|
|
1032
|
-
console.log(` Skipped : ${skipped}`);
|
|
1033
|
-
if (errorCount > 0) console.log(` Error : ${errorCount}`);
|
|
1034
|
-
console.log();
|
|
1035
|
-
|
|
1036
|
-
return { total: payloads.length, synced, skipped, error: errorCount };
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
/**
|
|
1040
|
-
* Regenerate payload berdasarkan perubahan yang terdeteksi.
|
|
1041
|
-
* Mempertahankan konfigurasi payload lama (action, filters, dll),
|
|
1042
|
-
* hanya update fieldName, fieldValidation, dateTimeFields, query, dan
|
|
1043
|
-
* (sejak Phase 03) field `auditColumns` bila terjadi misalignment dengan
|
|
1044
|
-
* tabel database.
|
|
1045
|
-
*
|
|
1046
|
-
* @param {Object} oldPayload - Payload JSON lama
|
|
1047
|
-
* @param {Object} comparison - Hasil comparePayloadWithDatabase
|
|
1048
|
-
* @param {Object} [options]
|
|
1049
|
-
* @param {string} [options.fileName] - Nama file payload untuk pesan log
|
|
1050
|
-
* @returns {Promise<Object>} Updated payload
|
|
1051
|
-
*/
|
|
1052
|
-
async regeneratePayload(oldPayload, comparison, options = {}) {
|
|
1053
|
-
const tableName = oldPayload.tableName;
|
|
1054
|
-
|
|
1055
|
-
// Ambil data terbaru dari database
|
|
1056
|
-
const dbColumns = await this.db.getColumns(tableName);
|
|
1057
|
-
const columnTypes = await this.db.getColumnTypes(tableName);
|
|
1058
|
-
const detailedColumns = await this.db.getDetailedColumnInfo(tableName);
|
|
1059
|
-
const primaryKey = await this.db.getPrimaryKey(tableName) || oldPayload.primaryKey;
|
|
1060
|
-
|
|
1061
|
-
// Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
|
|
1062
|
-
// Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
|
|
1063
|
-
// dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
|
|
1064
|
-
// ditambahkan otomatis karena di-handle runtime.
|
|
1065
|
-
const newFieldName = [];
|
|
1066
|
-
// Pertahankan field lama yang masih ada di database
|
|
1067
|
-
for (const field of oldPayload.fieldName) {
|
|
1068
|
-
if (dbColumns.includes(field)) {
|
|
1069
|
-
newFieldName.push(field);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
// Tambahkan field baru dari database, skip kolom audit default
|
|
1073
|
-
for (const col of dbColumns) {
|
|
1074
|
-
if (newFieldName.includes(col)) continue;
|
|
1075
|
-
if (DEFAULT_AUDIT_COLUMNS.includes(col)) continue;
|
|
1076
|
-
newFieldName.push(col);
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// Build payload baru, pertahankan konfigurasi lama
|
|
1080
|
-
const updatedPayload = { ...oldPayload };
|
|
1081
|
-
updatedPayload.primaryKey = primaryKey;
|
|
1082
|
-
updatedPayload.fieldName = newFieldName;
|
|
1083
|
-
|
|
1084
|
-
// Re-generate datatablesQuery
|
|
1085
|
-
const fieldsStr = newFieldName.join(', ');
|
|
1086
|
-
updatedPayload.datatablesQuery = `select ${fieldsStr} from ${tableName}`;
|
|
1087
|
-
|
|
1088
|
-
// Re-generate exportQuery jika ada
|
|
1089
|
-
if (oldPayload.exportQuery) {
|
|
1090
|
-
updatedPayload.exportQuery = `select ${fieldsStr} from ${tableName}`;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
// Re-generate datatablesWhere: pertahankan yang masih valid, tambahkan field baru yang searchable
|
|
1094
|
-
if (oldPayload.datatablesWhere) {
|
|
1095
|
-
const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
|
|
1096
|
-
const validWhere = oldPayload.datatablesWhere.filter(
|
|
1097
|
-
w => w === 'all' || newFieldName.includes(w)
|
|
1098
|
-
);
|
|
1099
|
-
// Tambahkan field baru yang searchable
|
|
1100
|
-
for (const col of comparison.added) {
|
|
1101
|
-
const fieldLower = col.column.toLowerCase();
|
|
1102
|
-
const isSearchable = !excludePatterns.some(pattern => fieldLower.includes(pattern));
|
|
1103
|
-
if (isSearchable && !validWhere.includes(col.column)) {
|
|
1104
|
-
// Sisipkan sebelum 'all'
|
|
1105
|
-
const allIdx = validWhere.indexOf('all');
|
|
1106
|
-
if (allIdx >= 0) {
|
|
1107
|
-
validWhere.splice(allIdx, 0, col.column);
|
|
1108
|
-
} else {
|
|
1109
|
-
validWhere.push(col.column);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
updatedPayload.datatablesWhere = validWhere;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
// Re-generate dateTimeFields
|
|
1117
|
-
const tempGenerator = new PayloadGenerator();
|
|
1118
|
-
const dateTimeFields = tempGenerator.generateDateTimeFields(columnTypes, newFieldName);
|
|
1119
|
-
if (Object.keys(dateTimeFields).length > 0) {
|
|
1120
|
-
updatedPayload.dateTimeFields = dateTimeFields;
|
|
1121
|
-
} else {
|
|
1122
|
-
delete updatedPayload.dateTimeFields;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// Re-generate fieldValidation
|
|
1126
|
-
const fieldValidation = tempGenerator.generateFieldValidation(detailedColumns, newFieldName, primaryKey);
|
|
1127
|
-
if (fieldValidation.length > 0) {
|
|
1128
|
-
updatedPayload.fieldValidation = fieldValidation;
|
|
1129
|
-
} else {
|
|
1130
|
-
delete updatedPayload.fieldValidation;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// Audit columns resolution (Phase 03):
|
|
1134
|
-
// Pastikan field `auditColumns` di payload aligned dengan struktur tabel
|
|
1135
|
-
// database. Logic ini mencegah inkonsistensi antara `payload sync` (yang
|
|
1136
|
-
// sebelumnya tidak menyentuh auditColumns) dan `endpoint create` (yang
|
|
1137
|
-
// sudah audit-column-aware sejak Phase 01).
|
|
1138
|
-
this.resolveAuditColumnsForSync(updatedPayload, oldPayload, dbColumns, {
|
|
1139
|
-
tableName,
|
|
1140
|
-
fileName: options.fileName
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
return updatedPayload;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Resolve nilai `auditColumns` untuk payload hasil sync. Memutasi
|
|
1148
|
-
* `updatedPayload` in-place.
|
|
1149
|
-
*
|
|
1150
|
-
* Aturan (lihat phase-03-audit-aware-sync.md):
|
|
1151
|
-
* - oldPayload.auditColumns === false/null -> preserve (no-op)
|
|
1152
|
-
* - oldPayload.auditColumns object form -> warn, no auto-override
|
|
1153
|
-
* - oldPayload.auditColumns === true + DB tidak full audit -> override
|
|
1154
|
-
* ke false + warning ke stderr
|
|
1155
|
-
* - oldPayload.auditColumns tidak di-set (default true) + DB tidak full
|
|
1156
|
-
* audit -> set false + info message
|
|
1157
|
-
* - DB punya keempat kolom audit standar -> preserve apapun bentuknya
|
|
1158
|
-
*
|
|
1159
|
-
* "DB tidak full audit" mencakup `none` (tidak satupun kolom audit) dan
|
|
1160
|
-
* `partial` (1-3 kolom audit). Partial di-treat sebagai incomplete: shape
|
|
1161
|
-
* audit tidak lengkap, generator tidak bisa inject 4 kolom sambil sebagian
|
|
1162
|
-
* tidak exist. Default strict yang aman.
|
|
1163
|
-
*
|
|
1164
|
-
* @param {Object} updatedPayload - Payload yang akan ditulis (mutated)
|
|
1165
|
-
* @param {Object} oldPayload - Payload lama (untuk inspect state asli)
|
|
1166
|
-
* @param {string[]} dbColumns - Daftar kolom database
|
|
1167
|
-
* @param {Object} ctx
|
|
1168
|
-
* @param {string} ctx.tableName - Nama table
|
|
1169
|
-
* @param {string} [ctx.fileName] - Nama file payload untuk pesan log
|
|
1170
|
-
* @returns {void}
|
|
1171
|
-
*/
|
|
1172
|
-
resolveAuditColumnsForSync(updatedPayload, oldPayload, dbColumns, ctx) {
|
|
1173
|
-
const tableName = ctx.tableName;
|
|
1174
|
-
const fileLabel = ctx.fileName || `${tableName}.json`;
|
|
1175
|
-
const alignment = detectAuditAlignment(dbColumns);
|
|
1176
|
-
const hasField = Object.prototype.hasOwnProperty.call(oldPayload, 'auditColumns');
|
|
1177
|
-
const value = hasField ? oldPayload.auditColumns : undefined;
|
|
1178
|
-
|
|
1179
|
-
// Case 1: explicit false/null -> preserve (no-op)
|
|
1180
|
-
if (value === false || value === null) {
|
|
1181
|
-
return;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
// Case 2: object form (custom column mapping) -> warn, no auto-override
|
|
1185
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1186
|
-
console.warn(
|
|
1187
|
-
`[restforge] Custom auditColumns object detected for "${tableName}". ` +
|
|
1188
|
-
'Cannot auto-resolve. Verify column names manually.'
|
|
1189
|
-
);
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
// Case 3: DB punya semua kolom audit -> preserve apa adanya
|
|
1194
|
-
if (alignment.all) {
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// Case 4: DB tidak full audit (none atau partial) + payload default/true
|
|
1199
|
-
// -> set updatedPayload.auditColumns = false
|
|
1200
|
-
if (value === true) {
|
|
1201
|
-
console.warn(
|
|
1202
|
-
`[restforge] Resetting auditColumns: true -> false for table "${tableName}" ` +
|
|
1203
|
-
'because audit columns missing in database'
|
|
1204
|
-
);
|
|
1205
|
-
} else {
|
|
1206
|
-
console.log(
|
|
1207
|
-
`[restforge] Set "auditColumns": false in ${fileLabel} ` +
|
|
1208
|
-
'because audit columns missing in database'
|
|
1209
|
-
);
|
|
1210
|
-
}
|
|
1211
|
-
updatedPayload.auditColumns = false;
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
module.exports = {
|
|
1216
|
-
PayloadGenerator,
|
|
1217
|
-
SchemaValidator
|
|
1218
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Payload Runner - Shared library untuk seluruh payload subcommand.
|
|
5
|
+
*
|
|
6
|
+
* Dipakai oleh 4 contract di generators/cli/payload/:
|
|
7
|
+
* - generate : non-interactive (--config dan --table wajib disertakan)
|
|
8
|
+
* - validate : mode --validate (read-only schema comparison)
|
|
9
|
+
* - diff : mode --diff (detailed schema comparison)
|
|
10
|
+
* - sync : mode --sync (update payload dari database, archive file lama)
|
|
11
|
+
*
|
|
12
|
+
* Inline migration v4.0.0: dipindah dari generators/create-payload.js
|
|
13
|
+
* ke generators/lib/payload/payload-runner.js. parseArguments()/showHelp()/main()
|
|
14
|
+
* dihapus karena cli-entry.js + contract handler menangani argv parsing.
|
|
15
|
+
* process.exit() di method run() dikonversi menjadi throw / return, sesuai
|
|
16
|
+
* konvensi inline migration (pattern guide: handler tidak boleh process.exit()).
|
|
17
|
+
*
|
|
18
|
+
* Exit code semantics:
|
|
19
|
+
* - run() return normal -> exit 0 via cli-entry.js
|
|
20
|
+
* - run() throw error -> exit 1 via cli-entry.js
|
|
21
|
+
* - Untuk mode validate/diff/sync, drift atau error sama-sama trigger throw
|
|
22
|
+
* (semantik "action required" untuk MCP/LSP).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const { DatabaseIntrospector, loadDriver } = require('../utils/database-introspector');
|
|
29
|
+
const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
|
|
30
|
+
const {
|
|
31
|
+
DEFAULT_AUDIT_COLUMNS,
|
|
32
|
+
detectAuditAlignment
|
|
33
|
+
} = require('../utils/audit-columns');
|
|
34
|
+
const { compareSchemaStrict } = require('./schema-diff');
|
|
35
|
+
|
|
36
|
+
// Kolom audit yang di-handle otomatis oleh RESTForge runtime (base-model).
|
|
37
|
+
// Konstanta dipakai dari shared util agar source-of-truth tunggal lintas
|
|
38
|
+
// modul (sync, validate, endpoint create). Kolom ini di-exclude dari payload
|
|
39
|
+
// hasil generate karena nilainya selalu di-set runtime saat create/update.
|
|
40
|
+
//
|
|
41
|
+
// Bila tabel tidak memiliki satupun kolom dari list ini, generator otomatis
|
|
42
|
+
// menyertakan "auditColumns": false di payload supaya template generator
|
|
43
|
+
// men-set this.auditColumns = null di model. Tanpa flag ini, runtime akan
|
|
44
|
+
// mencoba inject kolom audit ke INSERT dan menyebabkan error 500.
|
|
45
|
+
|
|
46
|
+
// Deteksi environment - Bun compiled binary atau Node.js/Bun script
|
|
47
|
+
const isBun = typeof Bun !== 'undefined';
|
|
48
|
+
|
|
49
|
+
// Detection logic untuk compiled binary:
|
|
50
|
+
// 1. Cek apakah argv[1] mengandung $bunfs (Bun virtual filesystem)
|
|
51
|
+
// 2. Cek apakah execPath bukan path ke bun binary standar
|
|
52
|
+
// 3. Cek apakah nama executable mengandung 'restforge'
|
|
53
|
+
function detectBunCompiled() {
|
|
54
|
+
if (!isBun) return false;
|
|
55
|
+
|
|
56
|
+
const hasVirtualFS = process.argv[1] && process.argv[1].includes('$bunfs');
|
|
57
|
+
const execPathLower = process.execPath.toLowerCase();
|
|
58
|
+
const isBunStandardPath = execPathLower.includes('bun') &&
|
|
59
|
+
(execPathLower.endsWith('bun') || execPathLower.endsWith('bun.exe'));
|
|
60
|
+
const isRestforgeBinary = execPathLower.includes('restforge');
|
|
61
|
+
|
|
62
|
+
return hasVirtualFS || isRestforgeBinary || !isBunStandardPath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isBunCompiled = detectBunCompiled();
|
|
66
|
+
const isCompiledBinary = isBunCompiled;
|
|
67
|
+
const isScript = !isCompiledBinary;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fungsi untuk mendapatkan working directory yang benar.
|
|
71
|
+
* Per pattern inline migration: panggil di handler-level (atau constructor),
|
|
72
|
+
* BUKAN di module-level. Sebab unit test melakukan chdir ke temp dir setelah
|
|
73
|
+
* module sudah di-load, sehingga snapshot module-level tidak ter-recognize.
|
|
74
|
+
* @returns {string} Working directory path
|
|
75
|
+
*/
|
|
76
|
+
function getWorkingDirectory() {
|
|
77
|
+
if (isCompiledBinary) {
|
|
78
|
+
const exeDir = path.dirname(process.execPath);
|
|
79
|
+
// Jika binary berada di node_modules (dijalankan via npx/npm),
|
|
80
|
+
// gunakan process.cwd() agar resolve ke project directory user
|
|
81
|
+
if (exeDir.includes('node_modules')) {
|
|
82
|
+
return process.cwd();
|
|
83
|
+
}
|
|
84
|
+
// Standalone binary: gunakan directory dimana binary berada
|
|
85
|
+
return exeDir;
|
|
86
|
+
} else {
|
|
87
|
+
return process.cwd();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Suppress experimental warnings untuk compiled binary environment
|
|
92
|
+
if (isCompiledBinary) {
|
|
93
|
+
process.removeAllListeners('warning');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Class untuk Payload Generator (non-interactive only)
|
|
99
|
+
*/
|
|
100
|
+
class PayloadGenerator {
|
|
101
|
+
constructor() {
|
|
102
|
+
this.workingDir = getWorkingDirectory();
|
|
103
|
+
this.db = new DatabaseIntrospector();
|
|
104
|
+
this.outputDir = path.join(this.workingDir, 'payload');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ensure output directory exists
|
|
109
|
+
*/
|
|
110
|
+
ensureOutputDir() {
|
|
111
|
+
if (!fs.existsSync(this.outputDir)) {
|
|
112
|
+
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
113
|
+
console.log(`Output directory created: ${this.outputDir}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generate dateTimeFields config from column types
|
|
119
|
+
* @param {Object} columnTypes - Map of column_name -> data_type
|
|
120
|
+
* @param {Array} fieldNames - Array of field names
|
|
121
|
+
* @returns {Object} dateTimeFields configuration
|
|
122
|
+
*/
|
|
123
|
+
generateDateTimeFields(columnTypes, fieldNames) {
|
|
124
|
+
const dateTimeFields = {};
|
|
125
|
+
|
|
126
|
+
for (const fieldName of fieldNames) {
|
|
127
|
+
const dataType = (columnTypes[fieldName] || '').toLowerCase();
|
|
128
|
+
|
|
129
|
+
if (dataType === 'date') {
|
|
130
|
+
dateTimeFields[fieldName] = {
|
|
131
|
+
type: 'date',
|
|
132
|
+
format: 'dd/MM/yyyy'
|
|
133
|
+
};
|
|
134
|
+
} else if (['timestamp', 'timestamp without time zone', 'timestamp with time zone'].includes(dataType)
|
|
135
|
+
|| dataType.startsWith('timestamp')) {
|
|
136
|
+
dateTimeFields[fieldName] = {
|
|
137
|
+
type: 'timestamp',
|
|
138
|
+
format: 'dd/MM/yyyy HH:mm'
|
|
139
|
+
};
|
|
140
|
+
} else if (['time', 'time without time zone', 'time with time zone'].includes(dataType)) {
|
|
141
|
+
dateTimeFields[fieldName] = {
|
|
142
|
+
type: 'time',
|
|
143
|
+
format: 'HH:mm'
|
|
144
|
+
};
|
|
145
|
+
} else if (dataType === 'datetime') {
|
|
146
|
+
// MySQL datetime type
|
|
147
|
+
dateTimeFields[fieldName] = {
|
|
148
|
+
type: 'timestamp',
|
|
149
|
+
format: 'dd/MM/yyyy HH:mm'
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return dateTimeFields;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate fieldValidation array from detailed column info
|
|
159
|
+
* Only includes "special" fields: primary key (UUID/auto-generate), numeric, boolean, date/time
|
|
160
|
+
* Regular string fields are excluded (treated as default)
|
|
161
|
+
* @param {Array} detailedColumns - Array from getDetailedColumnInfo()
|
|
162
|
+
* @param {Array} fieldNames - Array of selected field names
|
|
163
|
+
* @param {string} primaryKey - Primary key field name
|
|
164
|
+
* @returns {Array} fieldValidation array
|
|
165
|
+
*/
|
|
166
|
+
generateFieldValidation(detailedColumns, fieldNames, primaryKey) {
|
|
167
|
+
const fieldValidation = [];
|
|
168
|
+
|
|
169
|
+
// Build lookup map from detailed columns
|
|
170
|
+
const columnMap = {};
|
|
171
|
+
for (const col of detailedColumns) {
|
|
172
|
+
columnMap[col.column_name] = col;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const fieldName of fieldNames) {
|
|
176
|
+
const col = columnMap[fieldName];
|
|
177
|
+
if (!col) continue;
|
|
178
|
+
|
|
179
|
+
const dataType = (col.data_type || '').toLowerCase();
|
|
180
|
+
const udtName = (col.udt_name || '').toLowerCase();
|
|
181
|
+
const columnDefault = (col.column_default || '').toLowerCase();
|
|
182
|
+
const isPrimaryKey = fieldName === primaryKey;
|
|
183
|
+
|
|
184
|
+
// --- Primary key detection ---
|
|
185
|
+
// Native UUID type (PostgreSQL `uuid`) -> type: 'uuid' + primaryKey + autoGenerate.
|
|
186
|
+
//
|
|
187
|
+
// Varchar/text PK -> type: 'string' + primaryKey + autoGenerate + required.
|
|
188
|
+
// Berlaku baik untuk varchar PK dengan DEFAULT (PostgreSQL uuid_generate_v4,
|
|
189
|
+
// Oracle SYS_GUID) maupun tanpa DEFAULT (MySQL dengan BEFORE INSERT trigger
|
|
190
|
+
// UUID(), atau PK yang di-handle runtime). Output identik lintas database
|
|
191
|
+
// karena base-model runtime selalu auto-generate UUID via uuidv7() untuk PK
|
|
192
|
+
// kosong, independen dari DEFAULT/trigger database. Konvensi project:
|
|
193
|
+
// PostgreSQL: VARCHAR(70) PRIMARY KEY DEFAULT uuid_generate_v4()::VARCHAR
|
|
194
|
+
// Oracle: VARCHAR2(70) DEFAULT SYS_GUID() PRIMARY KEY
|
|
195
|
+
// MySQL: VARCHAR(70) PRIMARY KEY (UUID via trigger)
|
|
196
|
+
const isUuidNativeType = dataType === 'uuid' || udtName === 'uuid';
|
|
197
|
+
const isVarcharLikePk = isPrimaryKey
|
|
198
|
+
&& ['character varying', 'varchar', 'text'].includes(dataType)
|
|
199
|
+
&& !columnDefault.includes('nextval');
|
|
200
|
+
|
|
201
|
+
if (isPrimaryKey && isUuidNativeType) {
|
|
202
|
+
fieldValidation.push({
|
|
203
|
+
name: fieldName,
|
|
204
|
+
type: 'uuid',
|
|
205
|
+
constraints: {
|
|
206
|
+
primaryKey: true,
|
|
207
|
+
autoGenerate: true
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isVarcharLikePk) {
|
|
214
|
+
fieldValidation.push({
|
|
215
|
+
name: fieldName,
|
|
216
|
+
type: 'string',
|
|
217
|
+
constraints: {
|
|
218
|
+
primaryKey: true,
|
|
219
|
+
autoGenerate: true,
|
|
220
|
+
required: true
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- MySQL auto_increment primary key: SKIP ---
|
|
227
|
+
const extraInfo = (col.extra || '').toLowerCase();
|
|
228
|
+
if (isPrimaryKey && extraInfo.includes('auto_increment')) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- Integer types ---
|
|
233
|
+
// PostgreSQL: integer, bigint, smallint, serial, bigserial
|
|
234
|
+
// MySQL: int, tinyint, smallint, mediumint, bigint (normalized to lowercase by getDetailedColumnInfo)
|
|
235
|
+
// Oracle: already normalized to 'integer' by getDetailedColumnInfo when data_scale=0
|
|
236
|
+
if (['integer', 'bigint', 'smallint', 'serial', 'bigserial', 'smallserial',
|
|
237
|
+
'int', 'tinyint', 'mediumint'].includes(dataType) ||
|
|
238
|
+
['int4', 'int8', 'int2', 'serial4', 'serial8', 'serial2'].includes(udtName)) {
|
|
239
|
+
|
|
240
|
+
// Skip serial/auto-increment primary key
|
|
241
|
+
if (isPrimaryKey && (dataType.includes('serial') || columnDefault.includes('nextval'))) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Skip MySQL tinyint(1) — handled as boolean below
|
|
246
|
+
// (MySQL tinyint(1) is commonly used for boolean, but data_type from information_schema is 'tinyint')
|
|
247
|
+
// We'll check boolean pattern below instead
|
|
248
|
+
|
|
249
|
+
const entry = {
|
|
250
|
+
name: fieldName,
|
|
251
|
+
type: 'integer',
|
|
252
|
+
constraints: {}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Default value
|
|
256
|
+
if (col.column_default !== null && !(col.column_default + '').includes('nextval')) {
|
|
257
|
+
const defaultVal = parseInt(col.column_default, 10);
|
|
258
|
+
if (!isNaN(defaultVal)) {
|
|
259
|
+
entry.constraints.default = defaultVal;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fieldValidation.push(entry);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Numeric/Decimal types ---
|
|
268
|
+
// PostgreSQL: numeric, decimal, real, double precision
|
|
269
|
+
// MySQL: decimal, float, double
|
|
270
|
+
// Oracle: already normalized to 'numeric' by getDetailedColumnInfo when data_scale>0
|
|
271
|
+
if (['numeric', 'decimal', 'real', 'double precision', 'float', 'double'].includes(dataType) ||
|
|
272
|
+
['numeric', 'float4', 'float8'].includes(udtName)) {
|
|
273
|
+
|
|
274
|
+
const entry = {
|
|
275
|
+
name: fieldName,
|
|
276
|
+
type: 'number',
|
|
277
|
+
constraints: {}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Precision from database
|
|
281
|
+
if (col.numeric_precision !== null && col.numeric_scale !== null && col.numeric_scale > 0) {
|
|
282
|
+
entry.constraints.precision = col.numeric_scale;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Default value
|
|
286
|
+
if (col.column_default !== null) {
|
|
287
|
+
const defaultVal = parseFloat(col.column_default);
|
|
288
|
+
if (!isNaN(defaultVal)) {
|
|
289
|
+
entry.constraints.default = defaultVal;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fieldValidation.push(entry);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- Boolean type ---
|
|
298
|
+
// PostgreSQL: boolean / bool
|
|
299
|
+
// MySQL: tinyint(1) — NOT detected here, MySQL uses varchar with 'true'/'false' in RESTForge
|
|
300
|
+
// Oracle: uses varchar2 with CHECK constraint for 'true'/'false' in RESTForge
|
|
301
|
+
if (dataType === 'boolean' || udtName === 'bool') {
|
|
302
|
+
const entry = {
|
|
303
|
+
name: fieldName,
|
|
304
|
+
type: 'boolean',
|
|
305
|
+
constraints: {}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Default value
|
|
309
|
+
if (col.column_default !== null) {
|
|
310
|
+
if (columnDefault === 'true') {
|
|
311
|
+
entry.constraints.default = true;
|
|
312
|
+
} else if (columnDefault === 'false') {
|
|
313
|
+
entry.constraints.default = false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
fieldValidation.push(entry);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Date type ---
|
|
322
|
+
if (dataType === 'date') {
|
|
323
|
+
const entry = {
|
|
324
|
+
name: fieldName,
|
|
325
|
+
type: 'date',
|
|
326
|
+
constraints: {
|
|
327
|
+
format: 'dd/MM/yyyy'
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
fieldValidation.push(entry);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Timestamp / Datetime types ---
|
|
336
|
+
// PostgreSQL: timestamp, timestamp without time zone, timestamp with time zone
|
|
337
|
+
// MySQL: timestamp, datetime
|
|
338
|
+
// Oracle: already normalized to 'timestamp' by getDetailedColumnInfo
|
|
339
|
+
if (['timestamp', 'timestamp without time zone', 'timestamp with time zone', 'datetime'].includes(dataType)
|
|
340
|
+
|| dataType.startsWith('timestamp')) {
|
|
341
|
+
const entry = {
|
|
342
|
+
name: fieldName,
|
|
343
|
+
type: 'datetime',
|
|
344
|
+
constraints: {
|
|
345
|
+
format: 'dd/MM/yyyy HH:mm:ss'
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Detect auto-generate: now(), CURRENT_TIMESTAMP, SYSTIMESTAMP
|
|
350
|
+
if (columnDefault.includes('now') || columnDefault.includes('current_timestamp')
|
|
351
|
+
|| columnDefault.includes('systimestamp')) {
|
|
352
|
+
entry.constraints.autoGenerate = true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fieldValidation.push(entry);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- Time types ---
|
|
360
|
+
if (['time', 'time without time zone', 'time with time zone'].includes(dataType)) {
|
|
361
|
+
const entry = {
|
|
362
|
+
name: fieldName,
|
|
363
|
+
type: 'time',
|
|
364
|
+
constraints: {
|
|
365
|
+
format: 'HH:mm:ss'
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
fieldValidation.push(entry);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- String fields: SKIP (default behavior, tidak perlu di fieldValidation) ---
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return fieldValidation;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Main run method
|
|
382
|
+
* @param {Object} args - Command line arguments
|
|
383
|
+
*/
|
|
384
|
+
async run(args) {
|
|
385
|
+
try {
|
|
386
|
+
// MANDATORY: Database config harus disediakan (eksplisit via --config atau via default)
|
|
387
|
+
const resolved = resolveConfig(args.config, this.workingDir);
|
|
388
|
+
if (!resolved) {
|
|
389
|
+
console.error('='.repeat(60));
|
|
390
|
+
console.error('ERROR: Database configuration required');
|
|
391
|
+
console.error('='.repeat(60));
|
|
392
|
+
console.error();
|
|
393
|
+
console.error('Usage:');
|
|
394
|
+
console.error(' npx restforge payload generate --config=<filename.env> --table=<table-name>');
|
|
395
|
+
console.error();
|
|
396
|
+
console.error('Example:');
|
|
397
|
+
console.error(' npx restforge payload generate --config=mini-inventory.env --table=users');
|
|
398
|
+
console.error(' npx restforge payload generate --config=database.env --table=orders');
|
|
399
|
+
console.error();
|
|
400
|
+
console.error('The config file must be in the config/ directory or current directory.');
|
|
401
|
+
console.error();
|
|
402
|
+
console.error("Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs.");
|
|
403
|
+
console.error();
|
|
404
|
+
throw new Error('Database configuration required');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (resolved.source === 'default') {
|
|
408
|
+
printDefaultConfigWarning(resolved.defaultName);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Load database config
|
|
412
|
+
console.log('='.repeat(60));
|
|
413
|
+
console.log('Loading Database Configuration');
|
|
414
|
+
console.log('='.repeat(60));
|
|
415
|
+
console.log();
|
|
416
|
+
|
|
417
|
+
const configPath = resolved.path;
|
|
418
|
+
|
|
419
|
+
// Load config - throw jika gagal
|
|
420
|
+
if (!this.db.loadConfig(configPath)) {
|
|
421
|
+
console.error();
|
|
422
|
+
console.error('FATAL: Failed to load database configuration');
|
|
423
|
+
console.error(`Config file: ${configPath}`);
|
|
424
|
+
console.error();
|
|
425
|
+
throw new Error('Failed to load database configuration');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Connect to database - throw jika gagal
|
|
429
|
+
const connected = await this.db.connect();
|
|
430
|
+
if (!connected) {
|
|
431
|
+
console.error();
|
|
432
|
+
console.error('FATAL: Failed to connect to database');
|
|
433
|
+
console.error('Please check your database configuration and connection.');
|
|
434
|
+
console.error();
|
|
435
|
+
throw new Error('Failed to connect to database');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
console.log();
|
|
439
|
+
console.log('Database connected successfully');
|
|
440
|
+
console.log();
|
|
441
|
+
|
|
442
|
+
// Set output directory if provided
|
|
443
|
+
if (args.output) {
|
|
444
|
+
this.outputDir = path.isAbsolute(args.output)
|
|
445
|
+
? args.output
|
|
446
|
+
: path.join(this.workingDir, args.output);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Schema validation modes: --validate, --diff, --sync
|
|
450
|
+
if (args.validate || args.diff || args.sync) {
|
|
451
|
+
const validator = new SchemaValidator(this.db, this.outputDir);
|
|
452
|
+
const targetTable = args.table || null;
|
|
453
|
+
|
|
454
|
+
let result;
|
|
455
|
+
if (args.sync) {
|
|
456
|
+
result = await validator.runSync(targetTable, this);
|
|
457
|
+
} else if (args.diff) {
|
|
458
|
+
result = await validator.runDiff(targetTable);
|
|
459
|
+
} else {
|
|
460
|
+
result = await validator.runValidate(targetTable);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Cleanup
|
|
464
|
+
await this.db.close();
|
|
465
|
+
|
|
466
|
+
// Drift, error, atau target tidak ditemukan -> throw (exit 1 via cli-entry.js).
|
|
467
|
+
// Semantik "action required" untuk MCP/LSP integration.
|
|
468
|
+
// Marked silent karena verdict + summary sudah di-print oleh runValidate/
|
|
469
|
+
// runDiff/runSync; pesan "Error: Payload schema action required: ..."
|
|
470
|
+
// hanya menambah noise di stderr untuk human user.
|
|
471
|
+
const hasDrift = (result?.drift ?? 0) > 0;
|
|
472
|
+
const hasError = (result?.error ?? 0) > 0;
|
|
473
|
+
const targetMissing = targetTable && (result?.total ?? 0) === 0;
|
|
474
|
+
|
|
475
|
+
if (hasDrift || hasError || targetMissing) {
|
|
476
|
+
const reasons = [];
|
|
477
|
+
if (hasDrift) reasons.push(`drift=${result.drift}`);
|
|
478
|
+
if (hasError) reasons.push(`error=${result.error}`);
|
|
479
|
+
if (targetMissing) reasons.push(`target '${targetTable}' not found`);
|
|
480
|
+
const err = new Error(`Payload schema action required: ${reasons.join(', ')}`);
|
|
481
|
+
err.silent = true;
|
|
482
|
+
err.exitCode = 1;
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Default mode generate: --table wajib (interactive mode sudah dihapus)
|
|
490
|
+
await this.runNonInteractive(args);
|
|
491
|
+
|
|
492
|
+
await this.db.close();
|
|
493
|
+
|
|
494
|
+
console.log();
|
|
495
|
+
console.log('Done.');
|
|
496
|
+
console.log();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
// Cleanup resources lalu re-throw agar cli-entry.js handle exit code.
|
|
499
|
+
try { await this.db.close(); } catch (_e) { /* ignore close errors */ }
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Run in non-interactive mode
|
|
506
|
+
* @param {Object} args - Command line arguments
|
|
507
|
+
*/
|
|
508
|
+
async runNonInteractive(args) {
|
|
509
|
+
console.log();
|
|
510
|
+
console.log('Running in non-interactive mode...');
|
|
511
|
+
console.log();
|
|
512
|
+
|
|
513
|
+
// Pre-check: verify table exists before attempting introspection
|
|
514
|
+
if (this.db.pool) {
|
|
515
|
+
const exists = await this.db.tableExists(args.table);
|
|
516
|
+
if (!exists) {
|
|
517
|
+
console.error(`Error: Table '${args.table}' does not exist.`);
|
|
518
|
+
await this.db.close();
|
|
519
|
+
throw new Error(`Table '${args.table}' does not exist`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const payloadData = {
|
|
524
|
+
tableName: args.table,
|
|
525
|
+
primaryKey: args.primaryKey || null
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// Get primary key from database if not provided
|
|
529
|
+
if (!payloadData.primaryKey && this.db.pool) {
|
|
530
|
+
payloadData.primaryKey = await this.db.getPrimaryKey(args.table);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!payloadData.primaryKey) {
|
|
534
|
+
// Guess from table name
|
|
535
|
+
const tableNamePart = args.table.split('.').pop();
|
|
536
|
+
payloadData.primaryKey = tableNamePart.replace(/s$/, '') + '_id';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.log(`Table: ${payloadData.tableName}`);
|
|
540
|
+
console.log(`Primary Key: ${payloadData.primaryKey}`);
|
|
541
|
+
|
|
542
|
+
// Get fields from database
|
|
543
|
+
if (this.db.pool) {
|
|
544
|
+
const columns = await this.db.getColumns(args.table);
|
|
545
|
+
if (columns.length > 0) {
|
|
546
|
+
// Filter kolom audit yang di-handle otomatis oleh runtime.
|
|
547
|
+
const excludedPresent = columns.filter(col => DEFAULT_AUDIT_COLUMNS.includes(col));
|
|
548
|
+
payloadData.fieldName = columns.filter(col => !DEFAULT_AUDIT_COLUMNS.includes(col));
|
|
549
|
+
if (excludedPresent.length > 0) {
|
|
550
|
+
console.log(`Auto-managed columns excluded: ${excludedPresent.join(', ')}`);
|
|
551
|
+
} else {
|
|
552
|
+
// Tabel tidak punya satupun kolom audit standar: emit auditColumns: false
|
|
553
|
+
// supaya generator template men-set this.auditColumns = null di model.
|
|
554
|
+
payloadData.auditColumns = false;
|
|
555
|
+
console.log('No audit columns detected in table: setting auditColumns: false');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (!payloadData.fieldName || payloadData.fieldName.length === 0) {
|
|
561
|
+
console.error(`Error: Cannot create payload — no fields found for table '${args.table}'.`);
|
|
562
|
+
await this.db.close();
|
|
563
|
+
throw new Error(`No fields found for table '${args.table}'`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
console.log(`Fields: ${payloadData.fieldName.length}`);
|
|
567
|
+
|
|
568
|
+
// Generate query sebagai file external (file:query/...)
|
|
569
|
+
const baseFilename = args.table.replace(/[._]/g, '-');
|
|
570
|
+
const sqlFilename = `${baseFilename}-datatables.sql`;
|
|
571
|
+
const sqlLines = payloadData.fieldName.map((field, idx) => {
|
|
572
|
+
const prefix = idx === 0 ? 'SELECT ' : ' ';
|
|
573
|
+
const suffix = idx === payloadData.fieldName.length - 1 ? '' : ',';
|
|
574
|
+
return `${prefix}a.${field}${suffix}`;
|
|
575
|
+
});
|
|
576
|
+
const sqlContent = `${sqlLines.join('\n')}\nFROM ${payloadData.tableName} a\n`;
|
|
577
|
+
payloadData.datatablesQuery = `file:query/${sqlFilename}`;
|
|
578
|
+
|
|
579
|
+
// Generate searchable columns
|
|
580
|
+
const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
|
|
581
|
+
payloadData.datatablesWhere = payloadData.fieldName.filter(field => {
|
|
582
|
+
const fieldLower = field.toLowerCase();
|
|
583
|
+
return !excludePatterns.some(pattern => fieldLower.includes(pattern));
|
|
584
|
+
});
|
|
585
|
+
payloadData.datatablesWhere.push('all');
|
|
586
|
+
|
|
587
|
+
// Enable all actions
|
|
588
|
+
payloadData.action = {
|
|
589
|
+
datatables: true,
|
|
590
|
+
create: true,
|
|
591
|
+
update: true,
|
|
592
|
+
delete: true,
|
|
593
|
+
first: true,
|
|
594
|
+
lookup: true,
|
|
595
|
+
read: true
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// Generate dateTimeFields and fieldValidation
|
|
599
|
+
if (this.db.pool) {
|
|
600
|
+
const columnTypes = await this.db.getColumnTypes(args.table);
|
|
601
|
+
const dateTimeFields = this.generateDateTimeFields(columnTypes, payloadData.fieldName);
|
|
602
|
+
if (Object.keys(dateTimeFields).length > 0) {
|
|
603
|
+
payloadData.dateTimeFields = dateTimeFields;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Generate fieldValidation for special fields
|
|
607
|
+
const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
|
|
608
|
+
const fieldValidation = this.generateFieldValidation(detailedColumns, payloadData.fieldName, payloadData.primaryKey);
|
|
609
|
+
if (fieldValidation.length > 0) {
|
|
610
|
+
payloadData.fieldValidation = fieldValidation;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Save payload
|
|
615
|
+
this.ensureOutputDir();
|
|
616
|
+
const filename = baseFilename + '.json';
|
|
617
|
+
const outputPath = path.join(this.outputDir, filename);
|
|
618
|
+
|
|
619
|
+
// Tulis file SQL external ke payload/query/<table>-datatables.sql
|
|
620
|
+
const queryDir = path.join(this.outputDir, 'query');
|
|
621
|
+
if (!fs.existsSync(queryDir)) {
|
|
622
|
+
fs.mkdirSync(queryDir, { recursive: true });
|
|
623
|
+
}
|
|
624
|
+
const sqlOutputPath = path.join(queryDir, sqlFilename);
|
|
625
|
+
fs.writeFileSync(sqlOutputPath, sqlContent, 'utf8');
|
|
626
|
+
|
|
627
|
+
fs.writeFileSync(outputPath, JSON.stringify(payloadData, null, 4), 'utf8');
|
|
628
|
+
console.log();
|
|
629
|
+
console.log(`Payload saved: ${outputPath}`);
|
|
630
|
+
console.log(`Query saved: ${sqlOutputPath}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ============================================================================
|
|
635
|
+
// SCHEMA VALIDATOR - Validate & Diff payload vs database schema
|
|
636
|
+
// ============================================================================
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Class untuk validasi payload JSON terhadap schema database aktual.
|
|
640
|
+
* Mendukung mode: validate, diff, dan sync.
|
|
641
|
+
*/
|
|
642
|
+
class SchemaValidator {
|
|
643
|
+
/**
|
|
644
|
+
* @param {DatabaseIntrospector} db - Instance DatabaseIntrospector yang sudah terkoneksi
|
|
645
|
+
* @param {string} outputDir - Direktori payload
|
|
646
|
+
*/
|
|
647
|
+
constructor(db, outputDir) {
|
|
648
|
+
this.db = db;
|
|
649
|
+
this.outputDir = outputDir;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Cari semua file payload JSON di outputDir.
|
|
654
|
+
* @returns {Array<{filePath: string, fileName: string, payload: Object}>}
|
|
655
|
+
*/
|
|
656
|
+
findPayloadFiles() {
|
|
657
|
+
if (!fs.existsSync(this.outputDir)) {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const files = fs.readdirSync(this.outputDir)
|
|
662
|
+
.filter(f => f.endsWith('.json') && !f.includes('.archive.'));
|
|
663
|
+
|
|
664
|
+
const results = [];
|
|
665
|
+
for (const fileName of files) {
|
|
666
|
+
const filePath = path.join(this.outputDir, fileName);
|
|
667
|
+
try {
|
|
668
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
669
|
+
const payload = JSON.parse(content);
|
|
670
|
+
if (payload.tableName && payload.fieldName) {
|
|
671
|
+
results.push({ filePath, fileName, payload });
|
|
672
|
+
}
|
|
673
|
+
} catch (e) {
|
|
674
|
+
// Skip file yang bukan valid payload JSON
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return results;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Cari file payload untuk table tertentu.
|
|
682
|
+
* Pencocokan berdasarkan tableName di dalam payload atau nama file.
|
|
683
|
+
* @param {string} tableName - Nama table (misal: core.supplier atau supplier)
|
|
684
|
+
* @returns {Object|null} { filePath, fileName, payload } atau null
|
|
685
|
+
*/
|
|
686
|
+
findPayloadForTable(tableName) {
|
|
687
|
+
const allPayloads = this.findPayloadFiles();
|
|
688
|
+
|
|
689
|
+
// Exact match pada tableName
|
|
690
|
+
let match = allPayloads.find(p => p.payload.tableName === tableName);
|
|
691
|
+
if (match) return match;
|
|
692
|
+
|
|
693
|
+
// Match tanpa schema prefix
|
|
694
|
+
const tableOnly = tableName.includes('.') ? tableName.split('.').pop() : tableName;
|
|
695
|
+
match = allPayloads.find(p => {
|
|
696
|
+
const payloadTable = p.payload.tableName.includes('.')
|
|
697
|
+
? p.payload.tableName.split('.').pop()
|
|
698
|
+
: p.payload.tableName;
|
|
699
|
+
return payloadTable === tableOnly;
|
|
700
|
+
});
|
|
701
|
+
if (match) return match;
|
|
702
|
+
|
|
703
|
+
// Match berdasarkan filename pattern (kebab-case default + legacy snake_case)
|
|
704
|
+
const expectedFiles = [
|
|
705
|
+
tableName.replace(/[._]/g, '-') + '.json',
|
|
706
|
+
tableName.replace('.', '_') + '.json',
|
|
707
|
+
tableOnly.replace(/_/g, '-') + '.json',
|
|
708
|
+
tableOnly + '.json'
|
|
709
|
+
];
|
|
710
|
+
match = allPayloads.find(p => expectedFiles.includes(p.fileName));
|
|
711
|
+
return match || null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Format hasil perbandingan ke output console.
|
|
716
|
+
* Konsisten dengan `endpoint create` (audit-column-aware).
|
|
717
|
+
*
|
|
718
|
+
* @param {Object} comparison - Hasil dari compareSchemaStrict()
|
|
719
|
+
* @param {string} fileName - Nama file payload
|
|
720
|
+
* @param {boolean} showDiffDetail - Tampilkan detail per-column drift
|
|
721
|
+
*/
|
|
722
|
+
printComparisonResult(comparison, fileName, showDiffDetail = false) {
|
|
723
|
+
// Pad status icon ke lebar tetap 7 char ([DRIFT]/[ERROR]) supaya kolom
|
|
724
|
+
// fileName tampil rata antar baris.
|
|
725
|
+
const statusIcon = comparison.status === 'ok' ? '[OK] '
|
|
726
|
+
: comparison.status === 'drift' ? '[DRIFT]'
|
|
727
|
+
: '[ERROR]';
|
|
728
|
+
|
|
729
|
+
console.log(` ${statusIcon} ${fileName} (${comparison.tableName})`);
|
|
730
|
+
|
|
731
|
+
if (comparison.status === 'error') {
|
|
732
|
+
console.log(` ${comparison.summary}`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (comparison.status === 'ok') {
|
|
737
|
+
if (showDiffDetail) {
|
|
738
|
+
console.log(' Schema is in sync');
|
|
739
|
+
}
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Status: drift
|
|
744
|
+
console.log(` ${comparison.summary}`);
|
|
745
|
+
|
|
746
|
+
if (!showDiffDetail) return;
|
|
747
|
+
|
|
748
|
+
if (Array.isArray(comparison.removed)) {
|
|
749
|
+
for (const item of comparison.removed) {
|
|
750
|
+
console.log(` [-] ${item.column} (in payload, not in database)`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (Array.isArray(comparison.typeChanges)) {
|
|
754
|
+
for (const item of comparison.typeChanges) {
|
|
755
|
+
console.log(` [~] ${item.column} (type: ${item.payloadType} -> ${item.databaseType})`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (Array.isArray(comparison.added)) {
|
|
759
|
+
for (const item of comparison.added) {
|
|
760
|
+
const nullable = item.nullable ? ', nullable' : ', not null';
|
|
761
|
+
console.log(` [+] ${item.column} (${item.type}${nullable}) (in database, not in payload)`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (Array.isArray(comparison.auditMissing) && comparison.auditMissing.length > 0) {
|
|
765
|
+
const cols = comparison.auditMissing.join(', ');
|
|
766
|
+
console.log(` [+] ${cols}`);
|
|
767
|
+
console.log(' (required by auditColumns=true, not in database)');
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Jalankan validasi untuk satu table atau semua payload.
|
|
773
|
+
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
774
|
+
* @returns {Promise<Object>} { total, ok, drift, error, results }
|
|
775
|
+
*/
|
|
776
|
+
async runValidate(tableName) {
|
|
777
|
+
console.log();
|
|
778
|
+
console.log('='.repeat(60));
|
|
779
|
+
console.log('SCHEMA VALIDATION - Payload vs Database');
|
|
780
|
+
console.log('='.repeat(60));
|
|
781
|
+
console.log();
|
|
782
|
+
|
|
783
|
+
let payloads;
|
|
784
|
+
if (tableName) {
|
|
785
|
+
const found = this.findPayloadForTable(tableName);
|
|
786
|
+
if (!found) {
|
|
787
|
+
console.log(` Payload file not found for table: ${tableName}`);
|
|
788
|
+
console.log(` Searched in: ${this.outputDir}`);
|
|
789
|
+
console.log();
|
|
790
|
+
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
791
|
+
}
|
|
792
|
+
payloads = [found];
|
|
793
|
+
} else {
|
|
794
|
+
payloads = this.findPayloadFiles();
|
|
795
|
+
if (payloads.length === 0) {
|
|
796
|
+
console.log(` No payload files found in: ${this.outputDir}`);
|
|
797
|
+
console.log();
|
|
798
|
+
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
console.log(` Payload directory : ${this.outputDir}`);
|
|
803
|
+
console.log(` Files to validate : ${payloads.length}`);
|
|
804
|
+
console.log();
|
|
805
|
+
console.log('-'.repeat(60));
|
|
806
|
+
console.log();
|
|
807
|
+
|
|
808
|
+
const results = [];
|
|
809
|
+
let ok = 0, drift = 0, error = 0;
|
|
810
|
+
|
|
811
|
+
for (const { fileName, payload } of payloads) {
|
|
812
|
+
const comparison = await compareSchemaStrict(payload, this.db, { payloadDir: this.outputDir });
|
|
813
|
+
results.push({ fileName, comparison });
|
|
814
|
+
this.printComparisonResult(comparison, fileName, false);
|
|
815
|
+
|
|
816
|
+
if (comparison.status === 'ok') ok++;
|
|
817
|
+
else if (comparison.status === 'drift') drift++;
|
|
818
|
+
else error++;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Summary
|
|
822
|
+
console.log();
|
|
823
|
+
console.log('-'.repeat(60));
|
|
824
|
+
console.log();
|
|
825
|
+
console.log(' Summary:');
|
|
826
|
+
console.log(` Total : ${payloads.length} payload(s)`);
|
|
827
|
+
console.log(` OK : ${ok}`);
|
|
828
|
+
if (drift > 0) console.log(` Drift : ${drift}`);
|
|
829
|
+
if (error > 0) console.log(` Error : ${error}`);
|
|
830
|
+
console.log();
|
|
831
|
+
|
|
832
|
+
if (drift > 0 || error > 0) {
|
|
833
|
+
console.log(' Use --diff for detailed comparison.');
|
|
834
|
+
console.log(' Use --sync to update payload files automatically.');
|
|
835
|
+
console.log();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return { total: payloads.length, ok, drift, error, results };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Jalankan diff detail untuk satu table atau semua payload.
|
|
843
|
+
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
844
|
+
* @returns {Promise<Object>} { total, ok, drift, error, results }
|
|
845
|
+
*/
|
|
846
|
+
async runDiff(tableName) {
|
|
847
|
+
console.log();
|
|
848
|
+
console.log('='.repeat(60));
|
|
849
|
+
console.log('SCHEMA DIFF - Payload vs Database');
|
|
850
|
+
console.log('='.repeat(60));
|
|
851
|
+
console.log();
|
|
852
|
+
|
|
853
|
+
let payloads;
|
|
854
|
+
if (tableName) {
|
|
855
|
+
const found = this.findPayloadForTable(tableName);
|
|
856
|
+
if (!found) {
|
|
857
|
+
console.log(` Payload file not found for table: ${tableName}`);
|
|
858
|
+
console.log(` Searched in: ${this.outputDir}`);
|
|
859
|
+
console.log();
|
|
860
|
+
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
861
|
+
}
|
|
862
|
+
payloads = [found];
|
|
863
|
+
} else {
|
|
864
|
+
payloads = this.findPayloadFiles();
|
|
865
|
+
if (payloads.length === 0) {
|
|
866
|
+
console.log(` No payload files found in: ${this.outputDir}`);
|
|
867
|
+
console.log();
|
|
868
|
+
return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
console.log(` Payload directory : ${this.outputDir}`);
|
|
873
|
+
console.log(` Files to compare : ${payloads.length}`);
|
|
874
|
+
console.log();
|
|
875
|
+
|
|
876
|
+
const results = [];
|
|
877
|
+
let ok = 0, drift = 0, error = 0;
|
|
878
|
+
|
|
879
|
+
for (const { fileName, payload } of payloads) {
|
|
880
|
+
console.log('-'.repeat(60));
|
|
881
|
+
console.log();
|
|
882
|
+
|
|
883
|
+
const comparison = await compareSchemaStrict(payload, this.db, { payloadDir: this.outputDir });
|
|
884
|
+
results.push({ fileName, comparison });
|
|
885
|
+
this.printComparisonResult(comparison, fileName, true);
|
|
886
|
+
console.log();
|
|
887
|
+
|
|
888
|
+
if (comparison.status === 'ok') ok++;
|
|
889
|
+
else if (comparison.status === 'drift') drift++;
|
|
890
|
+
else error++;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Summary
|
|
894
|
+
console.log('-'.repeat(60));
|
|
895
|
+
console.log();
|
|
896
|
+
console.log(' Summary:');
|
|
897
|
+
console.log(` Total : ${payloads.length} payload(s)`);
|
|
898
|
+
console.log(` OK : ${ok}`);
|
|
899
|
+
if (drift > 0) console.log(` Drift : ${drift}`);
|
|
900
|
+
if (error > 0) console.log(` Error : ${error}`);
|
|
901
|
+
console.log();
|
|
902
|
+
|
|
903
|
+
if (drift > 0) {
|
|
904
|
+
console.log(' Use --sync to update payload files automatically.');
|
|
905
|
+
console.log();
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return { total: payloads.length, ok, drift, error, results };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Tentukan nomor archive berikutnya untuk sebuah file.
|
|
913
|
+
* Scan file .archive.001, .archive.002, dst. dan return nomor berikutnya.
|
|
914
|
+
* @param {string} filePath - Path file payload asli
|
|
915
|
+
* @returns {number} Nomor archive berikutnya
|
|
916
|
+
*/
|
|
917
|
+
getNextArchiveNumber(filePath) {
|
|
918
|
+
const dir = path.dirname(filePath);
|
|
919
|
+
const baseName = path.basename(filePath);
|
|
920
|
+
const files = fs.readdirSync(dir);
|
|
921
|
+
let maxNumber = 0;
|
|
922
|
+
|
|
923
|
+
const pattern = new RegExp(`^${baseName.replace(/\./g, '\\.')}\\.archive\\.(\\d+)$`);
|
|
924
|
+
for (const file of files) {
|
|
925
|
+
const match = file.match(pattern);
|
|
926
|
+
if (match) {
|
|
927
|
+
const num = parseInt(match[1], 10);
|
|
928
|
+
if (num > maxNumber) maxNumber = num;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return maxNumber + 1;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Archive file payload lama sebelum overwrite.
|
|
937
|
+
* Rename ke {filename}.archive.001, .002, dst.
|
|
938
|
+
* @param {string} filePath - Path file payload
|
|
939
|
+
* @returns {string} Path file archive yang dibuat
|
|
940
|
+
*/
|
|
941
|
+
archiveFile(filePath) {
|
|
942
|
+
const nextNum = this.getNextArchiveNumber(filePath);
|
|
943
|
+
const archiveName = `${filePath}.archive.${String(nextNum).padStart(3, '0')}`;
|
|
944
|
+
fs.renameSync(filePath, archiveName);
|
|
945
|
+
return archiveName;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Jalankan sync: update payload yang drift dengan data dari database.
|
|
950
|
+
* File lama di-archive sebelum overwrite.
|
|
951
|
+
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
952
|
+
* @param {PayloadGenerator} generator - Instance PayloadGenerator untuk re-generate
|
|
953
|
+
* @returns {Promise<Object>} { total, synced, skipped, error }
|
|
954
|
+
*/
|
|
955
|
+
async runSync(tableName, generator) {
|
|
956
|
+
console.log();
|
|
957
|
+
console.log('='.repeat(60));
|
|
958
|
+
console.log('SCHEMA SYNC - Update Payload from Database');
|
|
959
|
+
console.log('='.repeat(60));
|
|
960
|
+
console.log();
|
|
961
|
+
|
|
962
|
+
let payloads;
|
|
963
|
+
if (tableName) {
|
|
964
|
+
const found = this.findPayloadForTable(tableName);
|
|
965
|
+
if (!found) {
|
|
966
|
+
console.log(` Payload file not found for table: ${tableName}`);
|
|
967
|
+
console.log(` Searched in: ${this.outputDir}`);
|
|
968
|
+
console.log();
|
|
969
|
+
return { total: 0, synced: 0, skipped: 0, error: 0 };
|
|
970
|
+
}
|
|
971
|
+
payloads = [found];
|
|
972
|
+
} else {
|
|
973
|
+
payloads = this.findPayloadFiles();
|
|
974
|
+
if (payloads.length === 0) {
|
|
975
|
+
console.log(` No payload files found in: ${this.outputDir}`);
|
|
976
|
+
console.log();
|
|
977
|
+
return { total: 0, synced: 0, skipped: 0, error: 0 };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
console.log(` Payload directory : ${this.outputDir}`);
|
|
982
|
+
console.log(` Files to check : ${payloads.length}`);
|
|
983
|
+
console.log();
|
|
984
|
+
console.log('-'.repeat(60));
|
|
985
|
+
console.log();
|
|
986
|
+
|
|
987
|
+
let synced = 0, skipped = 0, errorCount = 0;
|
|
988
|
+
|
|
989
|
+
for (const { filePath, fileName, payload } of payloads) {
|
|
990
|
+
const comparison = await compareSchemaStrict(payload, this.db, { payloadDir: this.outputDir });
|
|
991
|
+
|
|
992
|
+
if (comparison.status === 'error') {
|
|
993
|
+
console.log(` [ERROR] ${fileName} - ${comparison.summary}`);
|
|
994
|
+
errorCount++;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (comparison.status === 'ok') {
|
|
999
|
+
console.log(` [SKIP] ${fileName} - already in sync`);
|
|
1000
|
+
skipped++;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Status: drift (termasuk audit drift) — archive + regenerate.
|
|
1005
|
+
const archivePath = this.archiveFile(filePath);
|
|
1006
|
+
const archiveName = path.basename(archivePath);
|
|
1007
|
+
console.log(` [ARCHIVE] ${fileName} -> ${archiveName}`);
|
|
1008
|
+
|
|
1009
|
+
// Re-generate payload dari database. regeneratePayload menerapkan audit
|
|
1010
|
+
// columns resolution secara internal (set auditColumns: false bila DB
|
|
1011
|
+
// tidak punya kolom audit standar).
|
|
1012
|
+
try {
|
|
1013
|
+
const updatedPayload = await this.regeneratePayload(payload, comparison, { fileName });
|
|
1014
|
+
fs.writeFileSync(filePath, JSON.stringify(updatedPayload, null, 4), 'utf8');
|
|
1015
|
+
console.log(` [SYNCED] ${fileName} - ${comparison.summary}`);
|
|
1016
|
+
synced++;
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
// Restore dari archive jika gagal
|
|
1019
|
+
fs.renameSync(archivePath, filePath);
|
|
1020
|
+
console.log(` [ERROR] ${fileName} - sync failed: ${e.message}`);
|
|
1021
|
+
errorCount++;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Summary
|
|
1026
|
+
console.log();
|
|
1027
|
+
console.log('-'.repeat(60));
|
|
1028
|
+
console.log();
|
|
1029
|
+
console.log(' Summary:');
|
|
1030
|
+
console.log(` Total : ${payloads.length} payload(s)`);
|
|
1031
|
+
console.log(` Synced : ${synced}`);
|
|
1032
|
+
console.log(` Skipped : ${skipped}`);
|
|
1033
|
+
if (errorCount > 0) console.log(` Error : ${errorCount}`);
|
|
1034
|
+
console.log();
|
|
1035
|
+
|
|
1036
|
+
return { total: payloads.length, synced, skipped, error: errorCount };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Regenerate payload berdasarkan perubahan yang terdeteksi.
|
|
1041
|
+
* Mempertahankan konfigurasi payload lama (action, filters, dll),
|
|
1042
|
+
* hanya update fieldName, fieldValidation, dateTimeFields, query, dan
|
|
1043
|
+
* (sejak Phase 03) field `auditColumns` bila terjadi misalignment dengan
|
|
1044
|
+
* tabel database.
|
|
1045
|
+
*
|
|
1046
|
+
* @param {Object} oldPayload - Payload JSON lama
|
|
1047
|
+
* @param {Object} comparison - Hasil comparePayloadWithDatabase
|
|
1048
|
+
* @param {Object} [options]
|
|
1049
|
+
* @param {string} [options.fileName] - Nama file payload untuk pesan log
|
|
1050
|
+
* @returns {Promise<Object>} Updated payload
|
|
1051
|
+
*/
|
|
1052
|
+
async regeneratePayload(oldPayload, comparison, options = {}) {
|
|
1053
|
+
const tableName = oldPayload.tableName;
|
|
1054
|
+
|
|
1055
|
+
// Ambil data terbaru dari database
|
|
1056
|
+
const dbColumns = await this.db.getColumns(tableName);
|
|
1057
|
+
const columnTypes = await this.db.getColumnTypes(tableName);
|
|
1058
|
+
const detailedColumns = await this.db.getDetailedColumnInfo(tableName);
|
|
1059
|
+
const primaryKey = await this.db.getPrimaryKey(tableName) || oldPayload.primaryKey;
|
|
1060
|
+
|
|
1061
|
+
// Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
|
|
1062
|
+
// Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
|
|
1063
|
+
// dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
|
|
1064
|
+
// ditambahkan otomatis karena di-handle runtime.
|
|
1065
|
+
const newFieldName = [];
|
|
1066
|
+
// Pertahankan field lama yang masih ada di database
|
|
1067
|
+
for (const field of oldPayload.fieldName) {
|
|
1068
|
+
if (dbColumns.includes(field)) {
|
|
1069
|
+
newFieldName.push(field);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Tambahkan field baru dari database, skip kolom audit default
|
|
1073
|
+
for (const col of dbColumns) {
|
|
1074
|
+
if (newFieldName.includes(col)) continue;
|
|
1075
|
+
if (DEFAULT_AUDIT_COLUMNS.includes(col)) continue;
|
|
1076
|
+
newFieldName.push(col);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Build payload baru, pertahankan konfigurasi lama
|
|
1080
|
+
const updatedPayload = { ...oldPayload };
|
|
1081
|
+
updatedPayload.primaryKey = primaryKey;
|
|
1082
|
+
updatedPayload.fieldName = newFieldName;
|
|
1083
|
+
|
|
1084
|
+
// Re-generate datatablesQuery
|
|
1085
|
+
const fieldsStr = newFieldName.join(', ');
|
|
1086
|
+
updatedPayload.datatablesQuery = `select ${fieldsStr} from ${tableName}`;
|
|
1087
|
+
|
|
1088
|
+
// Re-generate exportQuery jika ada
|
|
1089
|
+
if (oldPayload.exportQuery) {
|
|
1090
|
+
updatedPayload.exportQuery = `select ${fieldsStr} from ${tableName}`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Re-generate datatablesWhere: pertahankan yang masih valid, tambahkan field baru yang searchable
|
|
1094
|
+
if (oldPayload.datatablesWhere) {
|
|
1095
|
+
const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
|
|
1096
|
+
const validWhere = oldPayload.datatablesWhere.filter(
|
|
1097
|
+
w => w === 'all' || newFieldName.includes(w)
|
|
1098
|
+
);
|
|
1099
|
+
// Tambahkan field baru yang searchable
|
|
1100
|
+
for (const col of comparison.added) {
|
|
1101
|
+
const fieldLower = col.column.toLowerCase();
|
|
1102
|
+
const isSearchable = !excludePatterns.some(pattern => fieldLower.includes(pattern));
|
|
1103
|
+
if (isSearchable && !validWhere.includes(col.column)) {
|
|
1104
|
+
// Sisipkan sebelum 'all'
|
|
1105
|
+
const allIdx = validWhere.indexOf('all');
|
|
1106
|
+
if (allIdx >= 0) {
|
|
1107
|
+
validWhere.splice(allIdx, 0, col.column);
|
|
1108
|
+
} else {
|
|
1109
|
+
validWhere.push(col.column);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
updatedPayload.datatablesWhere = validWhere;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Re-generate dateTimeFields
|
|
1117
|
+
const tempGenerator = new PayloadGenerator();
|
|
1118
|
+
const dateTimeFields = tempGenerator.generateDateTimeFields(columnTypes, newFieldName);
|
|
1119
|
+
if (Object.keys(dateTimeFields).length > 0) {
|
|
1120
|
+
updatedPayload.dateTimeFields = dateTimeFields;
|
|
1121
|
+
} else {
|
|
1122
|
+
delete updatedPayload.dateTimeFields;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Re-generate fieldValidation
|
|
1126
|
+
const fieldValidation = tempGenerator.generateFieldValidation(detailedColumns, newFieldName, primaryKey);
|
|
1127
|
+
if (fieldValidation.length > 0) {
|
|
1128
|
+
updatedPayload.fieldValidation = fieldValidation;
|
|
1129
|
+
} else {
|
|
1130
|
+
delete updatedPayload.fieldValidation;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Audit columns resolution (Phase 03):
|
|
1134
|
+
// Pastikan field `auditColumns` di payload aligned dengan struktur tabel
|
|
1135
|
+
// database. Logic ini mencegah inkonsistensi antara `payload sync` (yang
|
|
1136
|
+
// sebelumnya tidak menyentuh auditColumns) dan `endpoint create` (yang
|
|
1137
|
+
// sudah audit-column-aware sejak Phase 01).
|
|
1138
|
+
this.resolveAuditColumnsForSync(updatedPayload, oldPayload, dbColumns, {
|
|
1139
|
+
tableName,
|
|
1140
|
+
fileName: options.fileName
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
return updatedPayload;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Resolve nilai `auditColumns` untuk payload hasil sync. Memutasi
|
|
1148
|
+
* `updatedPayload` in-place.
|
|
1149
|
+
*
|
|
1150
|
+
* Aturan (lihat phase-03-audit-aware-sync.md):
|
|
1151
|
+
* - oldPayload.auditColumns === false/null -> preserve (no-op)
|
|
1152
|
+
* - oldPayload.auditColumns object form -> warn, no auto-override
|
|
1153
|
+
* - oldPayload.auditColumns === true + DB tidak full audit -> override
|
|
1154
|
+
* ke false + warning ke stderr
|
|
1155
|
+
* - oldPayload.auditColumns tidak di-set (default true) + DB tidak full
|
|
1156
|
+
* audit -> set false + info message
|
|
1157
|
+
* - DB punya keempat kolom audit standar -> preserve apapun bentuknya
|
|
1158
|
+
*
|
|
1159
|
+
* "DB tidak full audit" mencakup `none` (tidak satupun kolom audit) dan
|
|
1160
|
+
* `partial` (1-3 kolom audit). Partial di-treat sebagai incomplete: shape
|
|
1161
|
+
* audit tidak lengkap, generator tidak bisa inject 4 kolom sambil sebagian
|
|
1162
|
+
* tidak exist. Default strict yang aman.
|
|
1163
|
+
*
|
|
1164
|
+
* @param {Object} updatedPayload - Payload yang akan ditulis (mutated)
|
|
1165
|
+
* @param {Object} oldPayload - Payload lama (untuk inspect state asli)
|
|
1166
|
+
* @param {string[]} dbColumns - Daftar kolom database
|
|
1167
|
+
* @param {Object} ctx
|
|
1168
|
+
* @param {string} ctx.tableName - Nama table
|
|
1169
|
+
* @param {string} [ctx.fileName] - Nama file payload untuk pesan log
|
|
1170
|
+
* @returns {void}
|
|
1171
|
+
*/
|
|
1172
|
+
resolveAuditColumnsForSync(updatedPayload, oldPayload, dbColumns, ctx) {
|
|
1173
|
+
const tableName = ctx.tableName;
|
|
1174
|
+
const fileLabel = ctx.fileName || `${tableName}.json`;
|
|
1175
|
+
const alignment = detectAuditAlignment(dbColumns);
|
|
1176
|
+
const hasField = Object.prototype.hasOwnProperty.call(oldPayload, 'auditColumns');
|
|
1177
|
+
const value = hasField ? oldPayload.auditColumns : undefined;
|
|
1178
|
+
|
|
1179
|
+
// Case 1: explicit false/null -> preserve (no-op)
|
|
1180
|
+
if (value === false || value === null) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Case 2: object form (custom column mapping) -> warn, no auto-override
|
|
1185
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1186
|
+
console.warn(
|
|
1187
|
+
`[restforge] Custom auditColumns object detected for "${tableName}". ` +
|
|
1188
|
+
'Cannot auto-resolve. Verify column names manually.'
|
|
1189
|
+
);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Case 3: DB punya semua kolom audit -> preserve apa adanya
|
|
1194
|
+
if (alignment.all) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Case 4: DB tidak full audit (none atau partial) + payload default/true
|
|
1199
|
+
// -> set updatedPayload.auditColumns = false
|
|
1200
|
+
if (value === true) {
|
|
1201
|
+
console.warn(
|
|
1202
|
+
`[restforge] Resetting auditColumns: true -> false for table "${tableName}" ` +
|
|
1203
|
+
'because audit columns missing in database'
|
|
1204
|
+
);
|
|
1205
|
+
} else {
|
|
1206
|
+
console.log(
|
|
1207
|
+
`[restforge] Set "auditColumns": false in ${fileLabel} ` +
|
|
1208
|
+
'because audit columns missing in database'
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
updatedPayload.auditColumns = false;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
module.exports = {
|
|
1216
|
+
PayloadGenerator,
|
|
1217
|
+
SchemaValidator
|
|
1218
|
+
};
|