@restforgejs/platform 4.3.4 → 4.3.8
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/payload/migrate.js +96 -0
- package/generators/lib/migrate/backend-payload-migrator.js +221 -0
- package/generators/lib/migrate/field-type-resolver.js +319 -0
- package/generators/lib/migrate/label-generator.js +38 -0
- package/generators/lib/migrate/migrate-runner.js +187 -0
- package/generators/lib/migrate/naming.js +43 -0
- package/generators/lib/migrate/sql-parser.js +124 -0
- package/generators/lib/payload/endpoint-schema-validator.js +181 -181
- package/generators/lib/payload/payload-runner.js +1313 -1218
- package/generators/lib/payload/schema-diff.js +460 -460
- 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/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,460 +1,460 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Schema Diff - Shared payload-vs-database comparison
|
|
5
|
-
*
|
|
6
|
-
* Module ini ekstraksi logic dari `SchemaValidator` di payload-runner.js untuk
|
|
7
|
-
* dipakai bersama oleh `endpoint create` (validasi pre-codegen) tanpa mengikat
|
|
8
|
-
* caller ke seluruh ergonomi `payload diff` command (mis. summary console output
|
|
9
|
-
* yang fokus ke batch validation).
|
|
10
|
-
*
|
|
11
|
-
* Audit-column-awareness:
|
|
12
|
-
* Default behavior `runtime` adalah inject 4 kolom audit (`created_at`,
|
|
13
|
-
* `created_by`, `updated_at`, `updated_by`) ke INSERT/UPDATE saat
|
|
14
|
-
* `payload.auditColumns` tidak di-set `false`/`null`. Fungsi ini meng-compute
|
|
15
|
-
* effective field list (eksplisit + audit) dan men-flag kolom audit yang
|
|
16
|
-
* *required* tapi tidak ada di database secara eksplisit, sehingga error
|
|
17
|
-
* runtime "column does not exist" dapat di-detect saat generate-time.
|
|
18
|
-
*
|
|
19
|
-
* Query-source-awareness:
|
|
20
|
-
* `fieldName` dapat berisi kolom hasil JOIN (mis. `category_name` dari
|
|
21
|
-
* `viewQuery`/`datatablesQuery`/`exportQuery`/`viewName`) sesuai spec
|
|
22
|
-
* `catalogs/rdf/data-source.md`. Validator meng-compute UNION columns dari
|
|
23
|
-
* seluruh query sources sebelum drift check, sehingga JOIN field tidak
|
|
24
|
-
* di-flag sebagai drift `[-]`.
|
|
25
|
-
*
|
|
26
|
-
* @module lib/payload/schema-diff
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const fs = require('fs');
|
|
30
|
-
const path = require('path');
|
|
31
|
-
const { resolveEffectiveFieldList, resolveAuditColumnNames, DEFAULT_AUDIT_COLUMNS } = require('../utils/audit-columns');
|
|
32
|
-
|
|
33
|
-
const DEFAULT_EXCLUDED_COLUMNS = DEFAULT_AUDIT_COLUMNS;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Normalisasi tipe dari fieldValidation payload ke kategori yang sama dengan
|
|
37
|
-
* `normalizeDatabaseType`. Dipakai untuk men-detect type drift antara payload
|
|
38
|
-
* dan database.
|
|
39
|
-
*
|
|
40
|
-
* @param {Object} validation - Entry dari payload.fieldValidation
|
|
41
|
-
* @returns {string|null}
|
|
42
|
-
*/
|
|
43
|
-
function normalizePayloadValidationType(validation) {
|
|
44
|
-
if (!validation) return null;
|
|
45
|
-
const t = (validation.type || '').toLowerCase();
|
|
46
|
-
switch (t) {
|
|
47
|
-
case 'uuid': return 'uuid';
|
|
48
|
-
case 'integer': return 'integer';
|
|
49
|
-
case 'number': return 'numeric';
|
|
50
|
-
case 'boolean': return 'boolean';
|
|
51
|
-
case 'date': return 'date';
|
|
52
|
-
case 'datetime': return 'timestamp';
|
|
53
|
-
case 'string': return 'varchar';
|
|
54
|
-
case 'json':
|
|
55
|
-
case 'array': return 'json';
|
|
56
|
-
default: return t || null;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Normalisasi tipe data database ke kategori umum untuk perbandingan
|
|
62
|
-
* (mirror dari SchemaValidator.normalizeType di payload-runner.js).
|
|
63
|
-
*
|
|
64
|
-
* @param {string} dataType
|
|
65
|
-
* @param {string} udtName
|
|
66
|
-
* @param {Object} col
|
|
67
|
-
* @returns {string}
|
|
68
|
-
*/
|
|
69
|
-
function normalizeDatabaseType(dataType, udtName, col) {
|
|
70
|
-
const dt = (dataType || '').toLowerCase();
|
|
71
|
-
const udt = (udtName || '').toLowerCase();
|
|
72
|
-
|
|
73
|
-
if (dt === 'uuid' || udt === 'uuid') return 'uuid';
|
|
74
|
-
if (dt === 'boolean' || udt === 'bool') return 'boolean';
|
|
75
|
-
|
|
76
|
-
if (['integer', 'bigint', 'smallint', 'serial', 'bigserial', 'smallserial',
|
|
77
|
-
'int', 'tinyint', 'mediumint'].includes(dt) ||
|
|
78
|
-
['int4', 'int8', 'int2', 'serial4', 'serial8', 'serial2'].includes(udt)) {
|
|
79
|
-
return 'integer';
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (['numeric', 'decimal', 'real', 'double precision', 'float', 'double'].includes(dt) ||
|
|
83
|
-
['numeric', 'float4', 'float8'].includes(udt)) {
|
|
84
|
-
return 'numeric';
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (['character varying', 'varchar', 'varchar2', 'nvarchar2', 'nvarchar'].includes(dt)) return 'varchar';
|
|
88
|
-
if (['text', 'clob', 'nclob', 'longtext', 'mediumtext', 'tinytext'].includes(dt)) return 'text';
|
|
89
|
-
if (['char', 'character', 'nchar'].includes(dt)) return 'char';
|
|
90
|
-
|
|
91
|
-
if (dt === 'date') return 'date';
|
|
92
|
-
if (dt === 'datetime') return 'timestamp';
|
|
93
|
-
if (dt.startsWith('timestamp')) return 'timestamp';
|
|
94
|
-
if (dt.startsWith('time')) return 'time';
|
|
95
|
-
|
|
96
|
-
if (['json', 'jsonb'].includes(dt) || ['json', 'jsonb'].includes(udt)) return 'json';
|
|
97
|
-
|
|
98
|
-
return dt || 'unknown';
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Bandingkan satu payload dengan schema database aktual.
|
|
103
|
-
*
|
|
104
|
-
* Mode strict ini audit-column-aware:
|
|
105
|
-
* - tidak men-skip kolom audit default dari sisi "added" (kolom audit yang
|
|
106
|
-
* di-required oleh payload tapi tidak ada di database dilaporkan sebagai
|
|
107
|
-
* `auditMissing`).
|
|
108
|
-
* - compute removed = field di payload (eksplisit) yang tidak ada di database
|
|
109
|
-
* - compute typeChanges = field yang ada di payload + database tapi tipenya
|
|
110
|
-
* beda (pakai `fieldValidation` di payload sebagai source of truth)
|
|
111
|
-
*
|
|
112
|
-
* Dipakai bersama oleh endpoint create, payload validate, payload diff, dan
|
|
113
|
-
* payload sync sebagai source of truth tunggal untuk drift detection.
|
|
114
|
-
*
|
|
115
|
-
* @param {Object} payload - Processed payload object
|
|
116
|
-
* @param {Object} db - DatabaseIntrospector terkoneksi
|
|
117
|
-
* @returns {Promise<{
|
|
118
|
-
* tableName: string,
|
|
119
|
-
* status: 'ok' | 'drift' | 'error',
|
|
120
|
-
* removed: Array<{ column: string, source: 'payload' | 'audit' }>,
|
|
121
|
-
* added: Array<{ column: string, type: string, nullable: boolean }>,
|
|
122
|
-
* typeChanges: Array<{ column: string, payloadType: string, databaseType: string }>,
|
|
123
|
-
* auditMissing: string[],
|
|
124
|
-
* summary: string
|
|
125
|
-
* }>}
|
|
126
|
-
*/
|
|
127
|
-
async function compareSchemaStrict(payload, db, options = {}) {
|
|
128
|
-
const tableName = payload.tableName;
|
|
129
|
-
const result = {
|
|
130
|
-
tableName,
|
|
131
|
-
status: 'ok',
|
|
132
|
-
removed: [],
|
|
133
|
-
added: [],
|
|
134
|
-
typeChanges: [],
|
|
135
|
-
auditMissing: [],
|
|
136
|
-
querySourceWarnings: [],
|
|
137
|
-
summary: ''
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const dbColumns = await db.getDetailedColumnInfo(tableName);
|
|
141
|
-
if (!Array.isArray(dbColumns) || dbColumns.length === 0) {
|
|
142
|
-
result.status = 'error';
|
|
143
|
-
result.summary = `Table "${tableName}" not found in database`;
|
|
144
|
-
return result;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const dbColumnSet = new Set(dbColumns.map(c => c.column_name));
|
|
148
|
-
const dbColumnList = dbColumns.map(c => c.column_name);
|
|
149
|
-
const dbColumnMap = {};
|
|
150
|
-
for (const col of dbColumns) {
|
|
151
|
-
dbColumnMap[col.column_name] = col;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Resolve UNION column dari query sources (viewName/viewQuery/datatablesQuery/exportQuery).
|
|
155
|
-
// JOIN field di fieldName (mis. category_name dari viewQuery) valid kalau ada di salah
|
|
156
|
-
// satu output query. Sesuai catalogs/rdf/data-source.md.
|
|
157
|
-
const querySourceResult = await resolveQuerySourceColumns(payload, db, options);
|
|
158
|
-
const validColumnSet = new Set(dbColumnSet);
|
|
159
|
-
for (const col of querySourceResult.columns) {
|
|
160
|
-
validColumnSet.add(col);
|
|
161
|
-
}
|
|
162
|
-
result.querySourceWarnings = querySourceResult.warnings;
|
|
163
|
-
|
|
164
|
-
const { explicit, audit } = resolveEffectiveFieldList(payload);
|
|
165
|
-
|
|
166
|
-
const explicitSet = new Set(explicit);
|
|
167
|
-
for (const field of explicit) {
|
|
168
|
-
if (!validColumnSet.has(field)) {
|
|
169
|
-
result.removed.push({ column: field, source: 'payload' });
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
for (const auditCol of audit) {
|
|
174
|
-
if (!dbColumnSet.has(auditCol)) {
|
|
175
|
-
result.auditMissing.push(auditCol);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
for (const dbCol of dbColumns) {
|
|
180
|
-
const name = dbCol.column_name;
|
|
181
|
-
if (explicitSet.has(name)) continue;
|
|
182
|
-
if (DEFAULT_EXCLUDED_COLUMNS.includes(name)) continue;
|
|
183
|
-
if (audit.includes(name)) continue;
|
|
184
|
-
result.added.push({
|
|
185
|
-
column: name,
|
|
186
|
-
type: normalizeDatabaseType(dbCol.data_type, dbCol.udt_name, dbCol),
|
|
187
|
-
nullable: dbCol.is_nullable === 'YES'
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Type drift: hanya untuk field yang ada di kedua sisi DAN punya entry
|
|
192
|
-
// di payload.fieldValidation. Tanpa fieldValidation, type drift tidak bisa
|
|
193
|
-
// di-detect (payload tidak men-claim tipe spesifik).
|
|
194
|
-
const validationMap = {};
|
|
195
|
-
if (Array.isArray(payload.fieldValidation)) {
|
|
196
|
-
for (const fv of payload.fieldValidation) {
|
|
197
|
-
if (fv && fv.name) validationMap[fv.name] = fv;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
for (const field of explicit) {
|
|
201
|
-
const dbCol = dbColumnMap[field];
|
|
202
|
-
if (!dbCol) continue;
|
|
203
|
-
const validation = validationMap[field];
|
|
204
|
-
if (!validation) continue;
|
|
205
|
-
|
|
206
|
-
const dbType = normalizeDatabaseType(dbCol.data_type, dbCol.udt_name, dbCol);
|
|
207
|
-
const payloadType = normalizePayloadValidationType(validation);
|
|
208
|
-
if (!payloadType) continue;
|
|
209
|
-
|
|
210
|
-
if (dbType !== payloadType) {
|
|
211
|
-
result.typeChanges.push({
|
|
212
|
-
column: field,
|
|
213
|
-
payloadType,
|
|
214
|
-
databaseType: dbType
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const driftCount = result.removed.length + result.added.length
|
|
220
|
-
+ result.auditMissing.length + result.typeChanges.length;
|
|
221
|
-
if (driftCount > 0) {
|
|
222
|
-
result.status = 'drift';
|
|
223
|
-
const parts = [];
|
|
224
|
-
if (result.removed.length > 0) parts.push(`${result.removed.length} payload field(s) missing from database`);
|
|
225
|
-
if (result.typeChanges.length > 0) parts.push(`${result.typeChanges.length} type change(s)`);
|
|
226
|
-
if (result.added.length > 0) parts.push(`${result.added.length} new database column(s) not in payload`);
|
|
227
|
-
if (result.auditMissing.length > 0) parts.push(`${result.auditMissing.length} audit column(s) required but missing`);
|
|
228
|
-
result.summary = parts.join(', ');
|
|
229
|
-
} else {
|
|
230
|
-
result.summary = 'Schema is in sync';
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
result.totalColumnsChecked = dbColumnList.length;
|
|
234
|
-
return result;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Format drift result ke output console untuk endpoint create.
|
|
239
|
-
* Format mengikuti spec phase-01-implementation.md:
|
|
240
|
-
* [-] kolom_a (in payload, not in database)
|
|
241
|
-
* [+] kolom_b (in database, not in payload)
|
|
242
|
-
* [+] created_at, ... (required by auditColumns=true, not in database)
|
|
243
|
-
*
|
|
244
|
-
* @param {Object} comparison - Hasil compareSchemaStrict()
|
|
245
|
-
* @param {Object} options
|
|
246
|
-
* @param {string} options.payloadFileName - Nama file payload (untuk resolution message)
|
|
247
|
-
* @param {string} options.tableName - Nama table
|
|
248
|
-
* @returns {string[]} Lines untuk di-print
|
|
249
|
-
*/
|
|
250
|
-
function formatDriftReport(comparison, options = {}) {
|
|
251
|
-
const lines = [];
|
|
252
|
-
const payloadFile = options.payloadFileName || comparison.tableName;
|
|
253
|
-
const tableName = comparison.tableName;
|
|
254
|
-
|
|
255
|
-
lines.push(' [BLOCKED] Payload-database drift detected:');
|
|
256
|
-
|
|
257
|
-
for (const item of comparison.removed) {
|
|
258
|
-
lines.push(` [-] ${item.column.padEnd(12)} (in payload, not in database)`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (Array.isArray(comparison.typeChanges)) {
|
|
262
|
-
for (const item of comparison.typeChanges) {
|
|
263
|
-
lines.push(` [~] ${item.column.padEnd(12)} (type: ${item.payloadType} -> ${item.databaseType})`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
for (const item of comparison.added) {
|
|
268
|
-
lines.push(` [+] ${item.column.padEnd(12)} (in database, not in payload)`);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (comparison.auditMissing.length > 0) {
|
|
272
|
-
const cols = comparison.auditMissing.join(', ');
|
|
273
|
-
lines.push(` [+] ${cols}`);
|
|
274
|
-
lines.push(' (required by auditColumns=true, not in database)');
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (Array.isArray(comparison.querySourceWarnings) && comparison.querySourceWarnings.length > 0) {
|
|
278
|
-
lines.push('');
|
|
279
|
-
lines.push(' Query source warnings (column resolution may be incomplete):');
|
|
280
|
-
for (const w of comparison.querySourceWarnings) {
|
|
281
|
-
lines.push(` [!] ${w.source}: ${w.message}`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
lines.push('');
|
|
286
|
-
lines.push(' Resolution:');
|
|
287
|
-
lines.push(` 1. Run: npx restforge payload sync --table=${tableName} --config=<ENV>`);
|
|
288
|
-
if (comparison.auditMissing.length > 0) {
|
|
289
|
-
lines.push(` 2. Add audit columns to schema OR set "auditColumns": false in payload/${payloadFile}`);
|
|
290
|
-
lines.push(' 3. Re-run endpoint create after resolving');
|
|
291
|
-
} else {
|
|
292
|
-
lines.push(' 2. Tambah kolom missing ke schema untuk match dengan payload');
|
|
293
|
-
lines.push(' 3. Re-run endpoint create after resolving');
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return lines;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Resolve UNION column dari query sources (viewName, viewQuery, datatablesQuery,
|
|
301
|
-
* exportQuery) untuk dipakai sebagai expanded valid column set saat drift check.
|
|
302
|
-
*
|
|
303
|
-
* `detailQuery` (di masterDetail.detailConfig) sengaja TIDAK di-resolve di scope
|
|
304
|
-
* root karena master-detail punya `fieldName` terpisah yang di-validate di scope
|
|
305
|
-
* detail sendiri.
|
|
306
|
-
*
|
|
307
|
-
* Behavior tolerant: query source yang gagal di-describe (SQL syntax error,
|
|
308
|
-
* file tidak ditemukan, dll) dicatat sebagai warning tapi tidak abort. Drift
|
|
309
|
-
* check tetap lanjut dengan UNION dari sources yang berhasil. Trade-off:
|
|
310
|
-
* false-positive drift mungkin terjadi kalau query gagal, tapi user dapat
|
|
311
|
-
* warning yang explicit.
|
|
312
|
-
*
|
|
313
|
-
* @param {Object} payload
|
|
314
|
-
* @param {Object} db - DatabaseIntrospector terkoneksi
|
|
315
|
-
* @param {Object} options
|
|
316
|
-
* @param {string} [options.payloadDir] - Folder payload untuk resolve file: reference
|
|
317
|
-
* @returns {Promise<{columns: string[], warnings: Array<{source: string, message: string}>}>}
|
|
318
|
-
*/
|
|
319
|
-
async function resolveQuerySourceColumns(payload, db, options = {}) {
|
|
320
|
-
const columns = new Set();
|
|
321
|
-
const warnings = [];
|
|
322
|
-
const payloadDir = options.payloadDir || null;
|
|
323
|
-
const tableName = payload.tableName;
|
|
324
|
-
|
|
325
|
-
// 1. viewName: introspect sebagai table (information_schema.columns covers VIEW)
|
|
326
|
-
if (typeof payload.viewName === 'string' && payload.viewName.trim().length > 0) {
|
|
327
|
-
try {
|
|
328
|
-
const viewCols = await db.getDetailedColumnInfo(payload.viewName.trim());
|
|
329
|
-
if (Array.isArray(viewCols) && viewCols.length > 0) {
|
|
330
|
-
for (const c of viewCols) columns.add(c.column_name);
|
|
331
|
-
} else {
|
|
332
|
-
warnings.push({
|
|
333
|
-
source: `viewName=${payload.viewName}`,
|
|
334
|
-
message: 'View not found or has no columns'
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
} catch (err) {
|
|
338
|
-
warnings.push({
|
|
339
|
-
source: `viewName=${payload.viewName}`,
|
|
340
|
-
message: err.message || String(err)
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// 2. SQL query sources
|
|
346
|
-
const sqlSources = [
|
|
347
|
-
{ key: 'viewQuery', value: payload.viewQuery },
|
|
348
|
-
{ key: 'datatablesQuery', value: payload.datatablesQuery },
|
|
349
|
-
{ key: 'exportQuery', value: payload.exportQuery }
|
|
350
|
-
];
|
|
351
|
-
|
|
352
|
-
for (const { key, value } of sqlSources) {
|
|
353
|
-
if (!value || typeof value !== 'string') continue;
|
|
354
|
-
|
|
355
|
-
const resolved = resolveSqlSource(value, payloadDir, key);
|
|
356
|
-
if (!resolved.ok) {
|
|
357
|
-
warnings.push({ source: key, message: resolved.error });
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const sql = substituteQueryPlaceholders(resolved.sql, tableName);
|
|
362
|
-
if (typeof db.describeQueryColumns !== 'function') {
|
|
363
|
-
// Backward compat: introspector versi lama tanpa describeQueryColumns.
|
|
364
|
-
// Skip silently agar tidak break caller existing.
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
const describe = await db.describeQueryColumns(sql);
|
|
370
|
-
if (describe.ok && Array.isArray(describe.columns)) {
|
|
371
|
-
for (const c of describe.columns) columns.add(c);
|
|
372
|
-
} else if (describe.error) {
|
|
373
|
-
warnings.push({
|
|
374
|
-
source: key,
|
|
375
|
-
message: `${describe.error.code}: ${describe.error.message}`
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
} catch (err) {
|
|
379
|
-
warnings.push({
|
|
380
|
-
source: key,
|
|
381
|
-
message: err.message || String(err)
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return {
|
|
387
|
-
columns: [...columns],
|
|
388
|
-
warnings
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Resolve SQL source dari string (inline SQL atau file: reference).
|
|
394
|
-
* Base path file: reference = options.payloadDir (folder payload/).
|
|
395
|
-
*
|
|
396
|
-
* @param {string} value - Inline SQL atau "file:relative/path.sql"
|
|
397
|
-
* @param {string|null} payloadDir - Base directory untuk file: reference
|
|
398
|
-
* @param {string} sourceKey - Key untuk error message context
|
|
399
|
-
* @returns {{ok: boolean, sql?: string, error?: string}}
|
|
400
|
-
*/
|
|
401
|
-
function resolveSqlSource(value, payloadDir, sourceKey) {
|
|
402
|
-
const trimmed = value.trim();
|
|
403
|
-
|
|
404
|
-
if (trimmed.startsWith('file:')) {
|
|
405
|
-
const relativePath = trimmed.substring(5);
|
|
406
|
-
if (!payloadDir) {
|
|
407
|
-
return {
|
|
408
|
-
ok: false,
|
|
409
|
-
error: `Cannot resolve file: reference (${relativePath}) — payloadDir not provided`
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
const fullPath = path.join(payloadDir, relativePath);
|
|
413
|
-
if (!fs.existsSync(fullPath)) {
|
|
414
|
-
return {
|
|
415
|
-
ok: false,
|
|
416
|
-
error: `Referenced SQL file not found: ${relativePath}`
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
try {
|
|
420
|
-
const content = fs.readFileSync(fullPath, 'utf8');
|
|
421
|
-
if (!content || content.trim().length === 0) {
|
|
422
|
-
return {
|
|
423
|
-
ok: false,
|
|
424
|
-
error: `Referenced SQL file is empty: ${relativePath}`
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
return { ok: true, sql: content };
|
|
428
|
-
} catch (err) {
|
|
429
|
-
return {
|
|
430
|
-
ok: false,
|
|
431
|
-
error: `Cannot read SQL file ${relativePath}: ${err.message}`
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
return { ok: true, sql: trimmed };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Substitusi placeholder runtime dalam SQL sebelum describe.
|
|
441
|
-
* Saat ini hanya `${tableName}` yang di-handle (konsisten dengan generator).
|
|
442
|
-
*
|
|
443
|
-
* @param {string} sql
|
|
444
|
-
* @param {string} tableName
|
|
445
|
-
* @returns {string}
|
|
446
|
-
*/
|
|
447
|
-
function substituteQueryPlaceholders(sql, tableName) {
|
|
448
|
-
if (!sql || !tableName) return sql;
|
|
449
|
-
return sql.replace(/\$\{tableName\}/g, tableName);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
module.exports = {
|
|
453
|
-
DEFAULT_EXCLUDED_COLUMNS,
|
|
454
|
-
normalizeDatabaseType,
|
|
455
|
-
normalizePayloadValidationType,
|
|
456
|
-
compareSchemaStrict,
|
|
457
|
-
formatDriftReport,
|
|
458
|
-
resolveQuerySourceColumns,
|
|
459
|
-
resolveSqlSource
|
|
460
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema Diff - Shared payload-vs-database comparison
|
|
5
|
+
*
|
|
6
|
+
* Module ini ekstraksi logic dari `SchemaValidator` di payload-runner.js untuk
|
|
7
|
+
* dipakai bersama oleh `endpoint create` (validasi pre-codegen) tanpa mengikat
|
|
8
|
+
* caller ke seluruh ergonomi `payload diff` command (mis. summary console output
|
|
9
|
+
* yang fokus ke batch validation).
|
|
10
|
+
*
|
|
11
|
+
* Audit-column-awareness:
|
|
12
|
+
* Default behavior `runtime` adalah inject 4 kolom audit (`created_at`,
|
|
13
|
+
* `created_by`, `updated_at`, `updated_by`) ke INSERT/UPDATE saat
|
|
14
|
+
* `payload.auditColumns` tidak di-set `false`/`null`. Fungsi ini meng-compute
|
|
15
|
+
* effective field list (eksplisit + audit) dan men-flag kolom audit yang
|
|
16
|
+
* *required* tapi tidak ada di database secara eksplisit, sehingga error
|
|
17
|
+
* runtime "column does not exist" dapat di-detect saat generate-time.
|
|
18
|
+
*
|
|
19
|
+
* Query-source-awareness:
|
|
20
|
+
* `fieldName` dapat berisi kolom hasil JOIN (mis. `category_name` dari
|
|
21
|
+
* `viewQuery`/`datatablesQuery`/`exportQuery`/`viewName`) sesuai spec
|
|
22
|
+
* `catalogs/rdf/data-source.md`. Validator meng-compute UNION columns dari
|
|
23
|
+
* seluruh query sources sebelum drift check, sehingga JOIN field tidak
|
|
24
|
+
* di-flag sebagai drift `[-]`.
|
|
25
|
+
*
|
|
26
|
+
* @module lib/payload/schema-diff
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { resolveEffectiveFieldList, resolveAuditColumnNames, DEFAULT_AUDIT_COLUMNS } = require('../utils/audit-columns');
|
|
32
|
+
|
|
33
|
+
const DEFAULT_EXCLUDED_COLUMNS = DEFAULT_AUDIT_COLUMNS;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalisasi tipe dari fieldValidation payload ke kategori yang sama dengan
|
|
37
|
+
* `normalizeDatabaseType`. Dipakai untuk men-detect type drift antara payload
|
|
38
|
+
* dan database.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} validation - Entry dari payload.fieldValidation
|
|
41
|
+
* @returns {string|null}
|
|
42
|
+
*/
|
|
43
|
+
function normalizePayloadValidationType(validation) {
|
|
44
|
+
if (!validation) return null;
|
|
45
|
+
const t = (validation.type || '').toLowerCase();
|
|
46
|
+
switch (t) {
|
|
47
|
+
case 'uuid': return 'uuid';
|
|
48
|
+
case 'integer': return 'integer';
|
|
49
|
+
case 'number': return 'numeric';
|
|
50
|
+
case 'boolean': return 'boolean';
|
|
51
|
+
case 'date': return 'date';
|
|
52
|
+
case 'datetime': return 'timestamp';
|
|
53
|
+
case 'string': return 'varchar';
|
|
54
|
+
case 'json':
|
|
55
|
+
case 'array': return 'json';
|
|
56
|
+
default: return t || null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalisasi tipe data database ke kategori umum untuk perbandingan
|
|
62
|
+
* (mirror dari SchemaValidator.normalizeType di payload-runner.js).
|
|
63
|
+
*
|
|
64
|
+
* @param {string} dataType
|
|
65
|
+
* @param {string} udtName
|
|
66
|
+
* @param {Object} col
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function normalizeDatabaseType(dataType, udtName, col) {
|
|
70
|
+
const dt = (dataType || '').toLowerCase();
|
|
71
|
+
const udt = (udtName || '').toLowerCase();
|
|
72
|
+
|
|
73
|
+
if (dt === 'uuid' || udt === 'uuid') return 'uuid';
|
|
74
|
+
if (dt === 'boolean' || udt === 'bool') return 'boolean';
|
|
75
|
+
|
|
76
|
+
if (['integer', 'bigint', 'smallint', 'serial', 'bigserial', 'smallserial',
|
|
77
|
+
'int', 'tinyint', 'mediumint'].includes(dt) ||
|
|
78
|
+
['int4', 'int8', 'int2', 'serial4', 'serial8', 'serial2'].includes(udt)) {
|
|
79
|
+
return 'integer';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (['numeric', 'decimal', 'real', 'double precision', 'float', 'double'].includes(dt) ||
|
|
83
|
+
['numeric', 'float4', 'float8'].includes(udt)) {
|
|
84
|
+
return 'numeric';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (['character varying', 'varchar', 'varchar2', 'nvarchar2', 'nvarchar'].includes(dt)) return 'varchar';
|
|
88
|
+
if (['text', 'clob', 'nclob', 'longtext', 'mediumtext', 'tinytext'].includes(dt)) return 'text';
|
|
89
|
+
if (['char', 'character', 'nchar'].includes(dt)) return 'char';
|
|
90
|
+
|
|
91
|
+
if (dt === 'date') return 'date';
|
|
92
|
+
if (dt === 'datetime') return 'timestamp';
|
|
93
|
+
if (dt.startsWith('timestamp')) return 'timestamp';
|
|
94
|
+
if (dt.startsWith('time')) return 'time';
|
|
95
|
+
|
|
96
|
+
if (['json', 'jsonb'].includes(dt) || ['json', 'jsonb'].includes(udt)) return 'json';
|
|
97
|
+
|
|
98
|
+
return dt || 'unknown';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Bandingkan satu payload dengan schema database aktual.
|
|
103
|
+
*
|
|
104
|
+
* Mode strict ini audit-column-aware:
|
|
105
|
+
* - tidak men-skip kolom audit default dari sisi "added" (kolom audit yang
|
|
106
|
+
* di-required oleh payload tapi tidak ada di database dilaporkan sebagai
|
|
107
|
+
* `auditMissing`).
|
|
108
|
+
* - compute removed = field di payload (eksplisit) yang tidak ada di database
|
|
109
|
+
* - compute typeChanges = field yang ada di payload + database tapi tipenya
|
|
110
|
+
* beda (pakai `fieldValidation` di payload sebagai source of truth)
|
|
111
|
+
*
|
|
112
|
+
* Dipakai bersama oleh endpoint create, payload validate, payload diff, dan
|
|
113
|
+
* payload sync sebagai source of truth tunggal untuk drift detection.
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} payload - Processed payload object
|
|
116
|
+
* @param {Object} db - DatabaseIntrospector terkoneksi
|
|
117
|
+
* @returns {Promise<{
|
|
118
|
+
* tableName: string,
|
|
119
|
+
* status: 'ok' | 'drift' | 'error',
|
|
120
|
+
* removed: Array<{ column: string, source: 'payload' | 'audit' }>,
|
|
121
|
+
* added: Array<{ column: string, type: string, nullable: boolean }>,
|
|
122
|
+
* typeChanges: Array<{ column: string, payloadType: string, databaseType: string }>,
|
|
123
|
+
* auditMissing: string[],
|
|
124
|
+
* summary: string
|
|
125
|
+
* }>}
|
|
126
|
+
*/
|
|
127
|
+
async function compareSchemaStrict(payload, db, options = {}) {
|
|
128
|
+
const tableName = payload.tableName;
|
|
129
|
+
const result = {
|
|
130
|
+
tableName,
|
|
131
|
+
status: 'ok',
|
|
132
|
+
removed: [],
|
|
133
|
+
added: [],
|
|
134
|
+
typeChanges: [],
|
|
135
|
+
auditMissing: [],
|
|
136
|
+
querySourceWarnings: [],
|
|
137
|
+
summary: ''
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const dbColumns = await db.getDetailedColumnInfo(tableName);
|
|
141
|
+
if (!Array.isArray(dbColumns) || dbColumns.length === 0) {
|
|
142
|
+
result.status = 'error';
|
|
143
|
+
result.summary = `Table "${tableName}" not found in database`;
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const dbColumnSet = new Set(dbColumns.map(c => c.column_name));
|
|
148
|
+
const dbColumnList = dbColumns.map(c => c.column_name);
|
|
149
|
+
const dbColumnMap = {};
|
|
150
|
+
for (const col of dbColumns) {
|
|
151
|
+
dbColumnMap[col.column_name] = col;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Resolve UNION column dari query sources (viewName/viewQuery/datatablesQuery/exportQuery).
|
|
155
|
+
// JOIN field di fieldName (mis. category_name dari viewQuery) valid kalau ada di salah
|
|
156
|
+
// satu output query. Sesuai catalogs/rdf/data-source.md.
|
|
157
|
+
const querySourceResult = await resolveQuerySourceColumns(payload, db, options);
|
|
158
|
+
const validColumnSet = new Set(dbColumnSet);
|
|
159
|
+
for (const col of querySourceResult.columns) {
|
|
160
|
+
validColumnSet.add(col);
|
|
161
|
+
}
|
|
162
|
+
result.querySourceWarnings = querySourceResult.warnings;
|
|
163
|
+
|
|
164
|
+
const { explicit, audit } = resolveEffectiveFieldList(payload);
|
|
165
|
+
|
|
166
|
+
const explicitSet = new Set(explicit);
|
|
167
|
+
for (const field of explicit) {
|
|
168
|
+
if (!validColumnSet.has(field)) {
|
|
169
|
+
result.removed.push({ column: field, source: 'payload' });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const auditCol of audit) {
|
|
174
|
+
if (!dbColumnSet.has(auditCol)) {
|
|
175
|
+
result.auditMissing.push(auditCol);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const dbCol of dbColumns) {
|
|
180
|
+
const name = dbCol.column_name;
|
|
181
|
+
if (explicitSet.has(name)) continue;
|
|
182
|
+
if (DEFAULT_EXCLUDED_COLUMNS.includes(name)) continue;
|
|
183
|
+
if (audit.includes(name)) continue;
|
|
184
|
+
result.added.push({
|
|
185
|
+
column: name,
|
|
186
|
+
type: normalizeDatabaseType(dbCol.data_type, dbCol.udt_name, dbCol),
|
|
187
|
+
nullable: dbCol.is_nullable === 'YES'
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Type drift: hanya untuk field yang ada di kedua sisi DAN punya entry
|
|
192
|
+
// di payload.fieldValidation. Tanpa fieldValidation, type drift tidak bisa
|
|
193
|
+
// di-detect (payload tidak men-claim tipe spesifik).
|
|
194
|
+
const validationMap = {};
|
|
195
|
+
if (Array.isArray(payload.fieldValidation)) {
|
|
196
|
+
for (const fv of payload.fieldValidation) {
|
|
197
|
+
if (fv && fv.name) validationMap[fv.name] = fv;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const field of explicit) {
|
|
201
|
+
const dbCol = dbColumnMap[field];
|
|
202
|
+
if (!dbCol) continue;
|
|
203
|
+
const validation = validationMap[field];
|
|
204
|
+
if (!validation) continue;
|
|
205
|
+
|
|
206
|
+
const dbType = normalizeDatabaseType(dbCol.data_type, dbCol.udt_name, dbCol);
|
|
207
|
+
const payloadType = normalizePayloadValidationType(validation);
|
|
208
|
+
if (!payloadType) continue;
|
|
209
|
+
|
|
210
|
+
if (dbType !== payloadType) {
|
|
211
|
+
result.typeChanges.push({
|
|
212
|
+
column: field,
|
|
213
|
+
payloadType,
|
|
214
|
+
databaseType: dbType
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const driftCount = result.removed.length + result.added.length
|
|
220
|
+
+ result.auditMissing.length + result.typeChanges.length;
|
|
221
|
+
if (driftCount > 0) {
|
|
222
|
+
result.status = 'drift';
|
|
223
|
+
const parts = [];
|
|
224
|
+
if (result.removed.length > 0) parts.push(`${result.removed.length} payload field(s) missing from database`);
|
|
225
|
+
if (result.typeChanges.length > 0) parts.push(`${result.typeChanges.length} type change(s)`);
|
|
226
|
+
if (result.added.length > 0) parts.push(`${result.added.length} new database column(s) not in payload`);
|
|
227
|
+
if (result.auditMissing.length > 0) parts.push(`${result.auditMissing.length} audit column(s) required but missing`);
|
|
228
|
+
result.summary = parts.join(', ');
|
|
229
|
+
} else {
|
|
230
|
+
result.summary = 'Schema is in sync';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
result.totalColumnsChecked = dbColumnList.length;
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Format drift result ke output console untuk endpoint create.
|
|
239
|
+
* Format mengikuti spec phase-01-implementation.md:
|
|
240
|
+
* [-] kolom_a (in payload, not in database)
|
|
241
|
+
* [+] kolom_b (in database, not in payload)
|
|
242
|
+
* [+] created_at, ... (required by auditColumns=true, not in database)
|
|
243
|
+
*
|
|
244
|
+
* @param {Object} comparison - Hasil compareSchemaStrict()
|
|
245
|
+
* @param {Object} options
|
|
246
|
+
* @param {string} options.payloadFileName - Nama file payload (untuk resolution message)
|
|
247
|
+
* @param {string} options.tableName - Nama table
|
|
248
|
+
* @returns {string[]} Lines untuk di-print
|
|
249
|
+
*/
|
|
250
|
+
function formatDriftReport(comparison, options = {}) {
|
|
251
|
+
const lines = [];
|
|
252
|
+
const payloadFile = options.payloadFileName || comparison.tableName;
|
|
253
|
+
const tableName = comparison.tableName;
|
|
254
|
+
|
|
255
|
+
lines.push(' [BLOCKED] Payload-database drift detected:');
|
|
256
|
+
|
|
257
|
+
for (const item of comparison.removed) {
|
|
258
|
+
lines.push(` [-] ${item.column.padEnd(12)} (in payload, not in database)`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (Array.isArray(comparison.typeChanges)) {
|
|
262
|
+
for (const item of comparison.typeChanges) {
|
|
263
|
+
lines.push(` [~] ${item.column.padEnd(12)} (type: ${item.payloadType} -> ${item.databaseType})`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const item of comparison.added) {
|
|
268
|
+
lines.push(` [+] ${item.column.padEnd(12)} (in database, not in payload)`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (comparison.auditMissing.length > 0) {
|
|
272
|
+
const cols = comparison.auditMissing.join(', ');
|
|
273
|
+
lines.push(` [+] ${cols}`);
|
|
274
|
+
lines.push(' (required by auditColumns=true, not in database)');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (Array.isArray(comparison.querySourceWarnings) && comparison.querySourceWarnings.length > 0) {
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push(' Query source warnings (column resolution may be incomplete):');
|
|
280
|
+
for (const w of comparison.querySourceWarnings) {
|
|
281
|
+
lines.push(` [!] ${w.source}: ${w.message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
lines.push('');
|
|
286
|
+
lines.push(' Resolution:');
|
|
287
|
+
lines.push(` 1. Run: npx restforge payload sync --table=${tableName} --config=<ENV>`);
|
|
288
|
+
if (comparison.auditMissing.length > 0) {
|
|
289
|
+
lines.push(` 2. Add audit columns to schema OR set "auditColumns": false in payload/${payloadFile}`);
|
|
290
|
+
lines.push(' 3. Re-run endpoint create after resolving');
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(' 2. Tambah kolom missing ke schema untuk match dengan payload');
|
|
293
|
+
lines.push(' 3. Re-run endpoint create after resolving');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolve UNION column dari query sources (viewName, viewQuery, datatablesQuery,
|
|
301
|
+
* exportQuery) untuk dipakai sebagai expanded valid column set saat drift check.
|
|
302
|
+
*
|
|
303
|
+
* `detailQuery` (di masterDetail.detailConfig) sengaja TIDAK di-resolve di scope
|
|
304
|
+
* root karena master-detail punya `fieldName` terpisah yang di-validate di scope
|
|
305
|
+
* detail sendiri.
|
|
306
|
+
*
|
|
307
|
+
* Behavior tolerant: query source yang gagal di-describe (SQL syntax error,
|
|
308
|
+
* file tidak ditemukan, dll) dicatat sebagai warning tapi tidak abort. Drift
|
|
309
|
+
* check tetap lanjut dengan UNION dari sources yang berhasil. Trade-off:
|
|
310
|
+
* false-positive drift mungkin terjadi kalau query gagal, tapi user dapat
|
|
311
|
+
* warning yang explicit.
|
|
312
|
+
*
|
|
313
|
+
* @param {Object} payload
|
|
314
|
+
* @param {Object} db - DatabaseIntrospector terkoneksi
|
|
315
|
+
* @param {Object} options
|
|
316
|
+
* @param {string} [options.payloadDir] - Folder payload untuk resolve file: reference
|
|
317
|
+
* @returns {Promise<{columns: string[], warnings: Array<{source: string, message: string}>}>}
|
|
318
|
+
*/
|
|
319
|
+
async function resolveQuerySourceColumns(payload, db, options = {}) {
|
|
320
|
+
const columns = new Set();
|
|
321
|
+
const warnings = [];
|
|
322
|
+
const payloadDir = options.payloadDir || null;
|
|
323
|
+
const tableName = payload.tableName;
|
|
324
|
+
|
|
325
|
+
// 1. viewName: introspect sebagai table (information_schema.columns covers VIEW)
|
|
326
|
+
if (typeof payload.viewName === 'string' && payload.viewName.trim().length > 0) {
|
|
327
|
+
try {
|
|
328
|
+
const viewCols = await db.getDetailedColumnInfo(payload.viewName.trim());
|
|
329
|
+
if (Array.isArray(viewCols) && viewCols.length > 0) {
|
|
330
|
+
for (const c of viewCols) columns.add(c.column_name);
|
|
331
|
+
} else {
|
|
332
|
+
warnings.push({
|
|
333
|
+
source: `viewName=${payload.viewName}`,
|
|
334
|
+
message: 'View not found or has no columns'
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
warnings.push({
|
|
339
|
+
source: `viewName=${payload.viewName}`,
|
|
340
|
+
message: err.message || String(err)
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 2. SQL query sources
|
|
346
|
+
const sqlSources = [
|
|
347
|
+
{ key: 'viewQuery', value: payload.viewQuery },
|
|
348
|
+
{ key: 'datatablesQuery', value: payload.datatablesQuery },
|
|
349
|
+
{ key: 'exportQuery', value: payload.exportQuery }
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
for (const { key, value } of sqlSources) {
|
|
353
|
+
if (!value || typeof value !== 'string') continue;
|
|
354
|
+
|
|
355
|
+
const resolved = resolveSqlSource(value, payloadDir, key);
|
|
356
|
+
if (!resolved.ok) {
|
|
357
|
+
warnings.push({ source: key, message: resolved.error });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const sql = substituteQueryPlaceholders(resolved.sql, tableName);
|
|
362
|
+
if (typeof db.describeQueryColumns !== 'function') {
|
|
363
|
+
// Backward compat: introspector versi lama tanpa describeQueryColumns.
|
|
364
|
+
// Skip silently agar tidak break caller existing.
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const describe = await db.describeQueryColumns(sql);
|
|
370
|
+
if (describe.ok && Array.isArray(describe.columns)) {
|
|
371
|
+
for (const c of describe.columns) columns.add(c);
|
|
372
|
+
} else if (describe.error) {
|
|
373
|
+
warnings.push({
|
|
374
|
+
source: key,
|
|
375
|
+
message: `${describe.error.code}: ${describe.error.message}`
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
warnings.push({
|
|
380
|
+
source: key,
|
|
381
|
+
message: err.message || String(err)
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
columns: [...columns],
|
|
388
|
+
warnings
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve SQL source dari string (inline SQL atau file: reference).
|
|
394
|
+
* Base path file: reference = options.payloadDir (folder payload/).
|
|
395
|
+
*
|
|
396
|
+
* @param {string} value - Inline SQL atau "file:relative/path.sql"
|
|
397
|
+
* @param {string|null} payloadDir - Base directory untuk file: reference
|
|
398
|
+
* @param {string} sourceKey - Key untuk error message context
|
|
399
|
+
* @returns {{ok: boolean, sql?: string, error?: string}}
|
|
400
|
+
*/
|
|
401
|
+
function resolveSqlSource(value, payloadDir, sourceKey) {
|
|
402
|
+
const trimmed = value.trim();
|
|
403
|
+
|
|
404
|
+
if (trimmed.startsWith('file:')) {
|
|
405
|
+
const relativePath = trimmed.substring(5);
|
|
406
|
+
if (!payloadDir) {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
error: `Cannot resolve file: reference (${relativePath}) — payloadDir not provided`
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const fullPath = path.join(payloadDir, relativePath);
|
|
413
|
+
if (!fs.existsSync(fullPath)) {
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
error: `Referenced SQL file not found: ${relativePath}`
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
421
|
+
if (!content || content.trim().length === 0) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
error: `Referenced SQL file is empty: ${relativePath}`
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return { ok: true, sql: content };
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: `Cannot read SQL file ${relativePath}: ${err.message}`
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { ok: true, sql: trimmed };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Substitusi placeholder runtime dalam SQL sebelum describe.
|
|
441
|
+
* Saat ini hanya `${tableName}` yang di-handle (konsisten dengan generator).
|
|
442
|
+
*
|
|
443
|
+
* @param {string} sql
|
|
444
|
+
* @param {string} tableName
|
|
445
|
+
* @returns {string}
|
|
446
|
+
*/
|
|
447
|
+
function substituteQueryPlaceholders(sql, tableName) {
|
|
448
|
+
if (!sql || !tableName) return sql;
|
|
449
|
+
return sql.replace(/\$\{tableName\}/g, tableName);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
module.exports = {
|
|
453
|
+
DEFAULT_EXCLUDED_COLUMNS,
|
|
454
|
+
normalizeDatabaseType,
|
|
455
|
+
normalizePayloadValidationType,
|
|
456
|
+
compareSchemaStrict,
|
|
457
|
+
formatDriftReport,
|
|
458
|
+
resolveQuerySourceColumns,
|
|
459
|
+
resolveSqlSource
|
|
460
|
+
};
|