@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.
Files changed (178) hide show
  1. package/build-info.json +2 -2
  2. package/cli/consumer-deploy.js +1 -1
  3. package/cli/consumer.js +1 -1
  4. package/generators/cli/payload/migrate.js +96 -0
  5. package/generators/lib/migrate/backend-payload-migrator.js +221 -0
  6. package/generators/lib/migrate/field-type-resolver.js +319 -0
  7. package/generators/lib/migrate/label-generator.js +38 -0
  8. package/generators/lib/migrate/migrate-runner.js +187 -0
  9. package/generators/lib/migrate/naming.js +43 -0
  10. package/generators/lib/migrate/sql-parser.js +124 -0
  11. package/generators/lib/payload/endpoint-schema-validator.js +181 -181
  12. package/generators/lib/payload/payload-runner.js +1313 -1218
  13. package/generators/lib/payload/schema-diff.js +460 -460
  14. package/generators/lib/templates/dashboard-catalog.js +1 -1
  15. package/generators/lib/templates/db-connection-env.js +1 -1
  16. package/generators/lib/templates/dbschema-catalog.js +1 -1
  17. package/generators/lib/templates/field-validation-catalog.js +1 -1
  18. package/generators/lib/templates/mysql-template.js +1 -1
  19. package/generators/lib/templates/oracle-template.js +1 -1
  20. package/generators/lib/templates/postgres-template.js +1 -1
  21. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  22. package/generators/lib/templates/sqlite-template.js +1 -1
  23. package/integrity-manifest.json +18 -18
  24. package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +1 -1
  25. package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
  26. package/package.json +1 -1
  27. package/scripts/verify-integrity.js +1 -1
  28. package/server.js +1 -1
  29. package/src/components/handlers/adjust_handler.js +1 -1
  30. package/src/components/handlers/audit_handler.js +1 -1
  31. package/src/components/handlers/delete_handler.js +1 -1
  32. package/src/components/handlers/export_handler.js +1 -1
  33. package/src/components/handlers/import_handler.js +1 -1
  34. package/src/components/handlers/insert_handler.js +1 -1
  35. package/src/components/handlers/update_handler.js +1 -1
  36. package/src/components/handlers/upload_handler.js +1 -1
  37. package/src/components/handlers/workflow_handler.js +1 -1
  38. package/src/components/integrations/webhook.js +1 -1
  39. package/src/consumers/baseConsumer.js +1 -1
  40. package/src/consumers/declarativeMapper.js +1 -1
  41. package/src/consumers/handlers/apiHandler.js +1 -1
  42. package/src/consumers/handlers/consoleHandler.js +1 -1
  43. package/src/consumers/handlers/databaseHandler.js +1 -1
  44. package/src/consumers/handlers/index.js +1 -1
  45. package/src/consumers/handlers/kafkaHandler.js +1 -1
  46. package/src/consumers/index.js +1 -1
  47. package/src/consumers/messageTransformer.js +1 -1
  48. package/src/consumers/validator.js +1 -1
  49. package/src/core/db/dialect/base-dialect.js +1 -1
  50. package/src/core/db/dialect/index.js +1 -1
  51. package/src/core/db/dialect/mysql-dialect.js +1 -1
  52. package/src/core/db/dialect/oracle-dialect.js +1 -1
  53. package/src/core/db/dialect/postgres-dialect.js +1 -1
  54. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  55. package/src/core/db/flatten-helper.js +1 -1
  56. package/src/core/db/query-builder-error.js +1 -1
  57. package/src/core/db/query-builder.js +1 -1
  58. package/src/core/db/relation-helper.js +1 -1
  59. package/src/core/handlers/delete_handler.js +1 -1
  60. package/src/core/handlers/insert_handler.js +1 -1
  61. package/src/core/handlers/update_handler.js +1 -1
  62. package/src/core/models/base-model.js +1 -1
  63. package/src/core/utils/cache-manager.js +1 -1
  64. package/src/core/utils/component-engine.js +1 -1
  65. package/src/core/utils/context-builder.js +1 -1
  66. package/src/core/utils/datetime-formatter.js +1 -1
  67. package/src/core/utils/datetime-parser.js +1 -1
  68. package/src/core/utils/db.js +1 -1
  69. package/src/core/utils/logger.js +1 -1
  70. package/src/core/utils/payload-loader.js +1 -1
  71. package/src/core/utils/security-checks.js +1 -1
  72. package/src/middleware/body-options.js +1 -1
  73. package/src/middleware/cors.js +1 -1
  74. package/src/middleware/idempotency.js +1 -1
  75. package/src/middleware/rate-limiter.js +1 -1
  76. package/src/middleware/request-logger.js +1 -1
  77. package/src/middleware/security-headers.js +1 -1
  78. package/src/models/base-model-mysql.js +1 -1
  79. package/src/models/base-model-oracle.js +1 -1
  80. package/src/models/base-model-sqlite.js +1 -1
  81. package/src/models/base-model.js +1 -1
  82. package/src/pro/caching/redis-client.js +1 -1
  83. package/src/pro/caching/redis-helper.js +1 -1
  84. package/src/pro/consumers/baseConsumer.js +1 -1
  85. package/src/pro/consumers/declarativeMapper.js +1 -1
  86. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  87. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  88. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  89. package/src/pro/consumers/handlers/index.js +1 -1
  90. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  91. package/src/pro/consumers/index.js +1 -1
  92. package/src/pro/consumers/messageTransformer.js +1 -1
  93. package/src/pro/consumers/validator.js +1 -1
  94. package/src/pro/database/base-model-mysql.js +1 -1
  95. package/src/pro/database/base-model-oracle.js +1 -1
  96. package/src/pro/database/base-model-sqlite.js +1 -1
  97. package/src/pro/database/db-mysql.js +1 -1
  98. package/src/pro/database/db-oracle.js +1 -1
  99. package/src/pro/database/db-sqlite.js +1 -1
  100. package/src/pro/excel/excel-generator.js +1 -1
  101. package/src/pro/excel/excel-parser.js +1 -1
  102. package/src/pro/excel/export-service.js +1 -1
  103. package/src/pro/excel/export_handler.js +1 -1
  104. package/src/pro/excel/import-service.js +1 -1
  105. package/src/pro/excel/import-validator.js +1 -1
  106. package/src/pro/excel/import_handler.js +1 -1
  107. package/src/pro/excel/upsert-builder.js +1 -1
  108. package/src/pro/idgen/idgen-routes.js +1 -1
  109. package/src/pro/integrations/lookup-resolver.js +1 -1
  110. package/src/pro/integrations/upload-handler-v2.js +1 -1
  111. package/src/pro/integrations/upload-handler.js +1 -1
  112. package/src/pro/integrations/webhook.js +1 -1
  113. package/src/pro/locking/lock-routes.js +1 -1
  114. package/src/pro/locking/resource-lock-manager.js +1 -1
  115. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  116. package/src/pro/messaging/kafkaService.js +1 -1
  117. package/src/pro/messaging/messagehubService.js +1 -1
  118. package/src/pro/messaging/rabbitmqService.js +1 -1
  119. package/src/pro/scheduler/job-manager.js +1 -1
  120. package/src/pro/scheduler/job-routes.js +1 -1
  121. package/src/pro/scheduler/job-validator.js +1 -1
  122. package/src/pro/storage/base-storage-provider.js +1 -1
  123. package/src/pro/storage/file-metadata-helper.js +1 -1
  124. package/src/pro/storage/index.js +1 -1
  125. package/src/pro/storage/local-storage-provider.js +1 -1
  126. package/src/pro/storage/s3-storage-provider.js +1 -1
  127. package/src/pro/storage/upload-cleanup-job.js +1 -1
  128. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  129. package/src/pro/storage/upload-pending-tracker.js +1 -1
  130. package/src/pro/websocket/broadcast-helper.js +1 -1
  131. package/src/pro/websocket/index.js +1 -1
  132. package/src/pro/websocket/livesync-server.js +1 -1
  133. package/src/pro/websocket/ws-broadcaster.js +1 -1
  134. package/src/services/export-service.js +1 -1
  135. package/src/services/import-service.js +1 -1
  136. package/src/services/kafkaConsumerService.js +1 -1
  137. package/src/services/kafkaService.js +1 -1
  138. package/src/services/messagehubService.js +1 -1
  139. package/src/services/rabbitmqService.js +1 -1
  140. package/src/utils/cache-invalidation-registry.js +1 -1
  141. package/src/utils/cache-manager.js +1 -1
  142. package/src/utils/component-engine.js +1 -1
  143. package/src/utils/config-extractor.js +1 -1
  144. package/src/utils/consumerLogger.js +1 -1
  145. package/src/utils/context-builder.js +1 -1
  146. package/src/utils/dashboard-helpers.js +1 -1
  147. package/src/utils/dateHelper.js +1 -1
  148. package/src/utils/datetime-formatter.js +1 -1
  149. package/src/utils/datetime-parser.js +1 -1
  150. package/src/utils/db-bootstrap.js +1 -1
  151. package/src/utils/db-mysql.js +1 -1
  152. package/src/utils/db-oracle.js +1 -1
  153. package/src/utils/db-sqlite.js +1 -1
  154. package/src/utils/db.js +1 -1
  155. package/src/utils/demo-generator.js +1 -1
  156. package/src/utils/excel-generator.js +1 -1
  157. package/src/utils/excel-parser.js +1 -1
  158. package/src/utils/file-watcher.js +1 -1
  159. package/src/utils/id-generator.js +1 -1
  160. package/src/utils/idempotency-manager.js +1 -1
  161. package/src/utils/import-validator.js +1 -1
  162. package/src/utils/license-client.js +1 -1
  163. package/src/utils/lock-manager.js +1 -1
  164. package/src/utils/logger.js +1 -1
  165. package/src/utils/lookup-resolver.js +1 -1
  166. package/src/utils/payload-loader.js +1 -1
  167. package/src/utils/processor-response.js +1 -1
  168. package/src/utils/rabbitmq.js +1 -1
  169. package/src/utils/redis-client.js +1 -1
  170. package/src/utils/redis-helper.js +1 -1
  171. package/src/utils/request-scope.js +1 -1
  172. package/src/utils/security-checks.js +1 -1
  173. package/src/utils/service-resolver.js +1 -1
  174. package/src/utils/shutdown-coordinator.js +1 -1
  175. package/src/utils/trusted-keys.js +1 -1
  176. package/src/utils/upload-handler.js +1 -1
  177. package/src/utils/upsert-builder.js +1 -1
  178. 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
+ };