@restforgejs/platform 5.0.9 → 5.1.4

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 (181) 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/data/pull.js +95 -0
  5. package/generators/cli/data/push.js +85 -0
  6. package/generators/cli/fast-track.js +950 -0
  7. package/generators/cli/payload/sync.js +18 -2
  8. package/generators/cli/schema/introspect.js +10 -10
  9. package/generators/lib/data/db-executor.js +440 -0
  10. package/generators/lib/data/dialect-kit.js +56 -0
  11. package/generators/lib/data/envelope.js +220 -0
  12. package/generators/lib/data/pull-runner.js +407 -0
  13. package/generators/lib/data/push-runner.js +382 -0
  14. package/generators/lib/data/sdf-reader.js +132 -0
  15. package/generators/lib/data/table-order.js +126 -0
  16. package/generators/lib/data/value-codec.js +188 -0
  17. package/generators/lib/migrate/field-type-resolver.js +18 -5
  18. package/generators/lib/payload/payload-runner.js +724 -39
  19. package/generators/lib/templates/dashboard-catalog.js +1 -1
  20. package/generators/lib/templates/db-connection-env.js +1 -1
  21. package/generators/lib/templates/dbschema-catalog.js +1 -1
  22. package/generators/lib/templates/field-validation-catalog.js +1 -1
  23. package/generators/lib/templates/mysql-template.js +1 -1
  24. package/generators/lib/templates/oracle-template.js +1 -1
  25. package/generators/lib/templates/postgres-template.js +1 -1
  26. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  27. package/generators/lib/templates/sqlite-template.js +1 -1
  28. package/integrity-manifest.json +18 -18
  29. package/package.json +1 -1
  30. package/scripts/verify-integrity.js +1 -1
  31. package/server.js +1 -1
  32. package/src/components/handlers/adjust_handler.js +1 -1
  33. package/src/components/handlers/audit_handler.js +1 -1
  34. package/src/components/handlers/delete_handler.js +1 -1
  35. package/src/components/handlers/export_handler.js +1 -1
  36. package/src/components/handlers/import_handler.js +1 -1
  37. package/src/components/handlers/insert_handler.js +1 -1
  38. package/src/components/handlers/update_handler.js +1 -1
  39. package/src/components/handlers/upload_handler.js +1 -1
  40. package/src/components/handlers/workflow_handler.js +1 -1
  41. package/src/components/integrations/webhook.js +1 -1
  42. package/src/consumers/baseConsumer.js +1 -1
  43. package/src/consumers/declarativeMapper.js +1 -1
  44. package/src/consumers/handlers/apiHandler.js +1 -1
  45. package/src/consumers/handlers/consoleHandler.js +1 -1
  46. package/src/consumers/handlers/databaseHandler.js +1 -1
  47. package/src/consumers/handlers/index.js +1 -1
  48. package/src/consumers/handlers/kafkaHandler.js +1 -1
  49. package/src/consumers/index.js +1 -1
  50. package/src/consumers/messageTransformer.js +1 -1
  51. package/src/consumers/validator.js +1 -1
  52. package/src/core/db/dialect/base-dialect.js +1 -1
  53. package/src/core/db/dialect/index.js +1 -1
  54. package/src/core/db/dialect/mysql-dialect.js +1 -1
  55. package/src/core/db/dialect/oracle-dialect.js +1 -1
  56. package/src/core/db/dialect/postgres-dialect.js +1 -1
  57. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  58. package/src/core/db/flatten-helper.js +1 -1
  59. package/src/core/db/query-builder-error.js +1 -1
  60. package/src/core/db/query-builder.js +1 -1
  61. package/src/core/db/relation-helper.js +1 -1
  62. package/src/core/handlers/delete_handler.js +1 -1
  63. package/src/core/handlers/insert_handler.js +1 -1
  64. package/src/core/handlers/update_handler.js +1 -1
  65. package/src/core/models/base-model.js +1 -1
  66. package/src/core/utils/cache-manager.js +1 -1
  67. package/src/core/utils/component-engine.js +1 -1
  68. package/src/core/utils/context-builder.js +1 -1
  69. package/src/core/utils/datetime-formatter.js +1 -1
  70. package/src/core/utils/datetime-parser.js +1 -1
  71. package/src/core/utils/db.js +1 -1
  72. package/src/core/utils/logger.js +1 -1
  73. package/src/core/utils/payload-loader.js +1 -1
  74. package/src/core/utils/security-checks.js +1 -1
  75. package/src/middleware/body-options.js +1 -1
  76. package/src/middleware/cors.js +1 -1
  77. package/src/middleware/idempotency.js +1 -1
  78. package/src/middleware/rate-limiter.js +1 -1
  79. package/src/middleware/request-logger.js +1 -1
  80. package/src/middleware/security-headers.js +1 -1
  81. package/src/models/base-model-mysql.js +1 -1
  82. package/src/models/base-model-oracle.js +1 -1
  83. package/src/models/base-model-sqlite.js +1 -1
  84. package/src/models/base-model.js +1 -1
  85. package/src/pro/caching/redis-client.js +1 -1
  86. package/src/pro/caching/redis-helper.js +1 -1
  87. package/src/pro/consumers/baseConsumer.js +1 -1
  88. package/src/pro/consumers/declarativeMapper.js +1 -1
  89. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  90. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  91. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  92. package/src/pro/consumers/handlers/index.js +1 -1
  93. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  94. package/src/pro/consumers/index.js +1 -1
  95. package/src/pro/consumers/messageTransformer.js +1 -1
  96. package/src/pro/consumers/validator.js +1 -1
  97. package/src/pro/database/base-model-mysql.js +1 -1
  98. package/src/pro/database/base-model-oracle.js +1 -1
  99. package/src/pro/database/base-model-sqlite.js +1 -1
  100. package/src/pro/database/db-mysql.js +1 -1
  101. package/src/pro/database/db-oracle.js +1 -1
  102. package/src/pro/database/db-sqlite.js +1 -1
  103. package/src/pro/excel/excel-generator.js +1 -1
  104. package/src/pro/excel/excel-parser.js +1 -1
  105. package/src/pro/excel/export-service.js +1 -1
  106. package/src/pro/excel/export_handler.js +1 -1
  107. package/src/pro/excel/import-service.js +1 -1
  108. package/src/pro/excel/import-validator.js +1 -1
  109. package/src/pro/excel/import_handler.js +1 -1
  110. package/src/pro/excel/upsert-builder.js +1 -1
  111. package/src/pro/idgen/idgen-routes.js +1 -1
  112. package/src/pro/integrations/lookup-resolver.js +1 -1
  113. package/src/pro/integrations/upload-handler-v2.js +1 -1
  114. package/src/pro/integrations/upload-handler.js +1 -1
  115. package/src/pro/integrations/webhook.js +1 -1
  116. package/src/pro/locking/lock-routes.js +1 -1
  117. package/src/pro/locking/resource-lock-manager.js +1 -1
  118. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  119. package/src/pro/messaging/kafkaService.js +1 -1
  120. package/src/pro/messaging/messagehubService.js +1 -1
  121. package/src/pro/messaging/rabbitmqService.js +1 -1
  122. package/src/pro/scheduler/job-manager.js +1 -1
  123. package/src/pro/scheduler/job-routes.js +1 -1
  124. package/src/pro/scheduler/job-validator.js +1 -1
  125. package/src/pro/storage/base-storage-provider.js +1 -1
  126. package/src/pro/storage/file-metadata-helper.js +1 -1
  127. package/src/pro/storage/index.js +1 -1
  128. package/src/pro/storage/local-storage-provider.js +1 -1
  129. package/src/pro/storage/s3-storage-provider.js +1 -1
  130. package/src/pro/storage/upload-cleanup-job.js +1 -1
  131. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  132. package/src/pro/storage/upload-pending-tracker.js +1 -1
  133. package/src/pro/websocket/broadcast-helper.js +1 -1
  134. package/src/pro/websocket/index.js +1 -1
  135. package/src/pro/websocket/livesync-server.js +1 -1
  136. package/src/pro/websocket/ws-broadcaster.js +1 -1
  137. package/src/services/export-service.js +1 -1
  138. package/src/services/import-service.js +1 -1
  139. package/src/services/kafkaConsumerService.js +1 -1
  140. package/src/services/kafkaService.js +1 -1
  141. package/src/services/messagehubService.js +1 -1
  142. package/src/services/rabbitmqService.js +1 -1
  143. package/src/utils/cache-invalidation-registry.js +1 -1
  144. package/src/utils/cache-manager.js +1 -1
  145. package/src/utils/component-engine.js +1 -1
  146. package/src/utils/config-extractor.js +1 -1
  147. package/src/utils/consumerLogger.js +1 -1
  148. package/src/utils/context-builder.js +1 -1
  149. package/src/utils/dashboard-helpers.js +1 -1
  150. package/src/utils/dateHelper.js +1 -1
  151. package/src/utils/datetime-formatter.js +1 -1
  152. package/src/utils/datetime-parser.js +1 -1
  153. package/src/utils/db-bootstrap.js +1 -1
  154. package/src/utils/db-mysql.js +1 -1
  155. package/src/utils/db-oracle.js +1 -1
  156. package/src/utils/db-sqlite.js +1 -1
  157. package/src/utils/db.js +1 -1
  158. package/src/utils/demo-generator.js +1 -1
  159. package/src/utils/excel-generator.js +1 -1
  160. package/src/utils/excel-parser.js +1 -1
  161. package/src/utils/file-watcher.js +1 -1
  162. package/src/utils/id-generator.js +1 -1
  163. package/src/utils/idempotency-manager.js +1 -1
  164. package/src/utils/import-validator.js +1 -1
  165. package/src/utils/license-client.js +1 -1
  166. package/src/utils/lock-manager.js +1 -1
  167. package/src/utils/logger.js +1 -1
  168. package/src/utils/lookup-resolver.js +1 -1
  169. package/src/utils/payload-loader.js +1 -1
  170. package/src/utils/processor-response.js +1 -1
  171. package/src/utils/rabbitmq.js +1 -1
  172. package/src/utils/redis-client.js +1 -1
  173. package/src/utils/redis-helper.js +1 -1
  174. package/src/utils/request-scope.js +1 -1
  175. package/src/utils/security-checks.js +1 -1
  176. package/src/utils/service-resolver.js +1 -1
  177. package/src/utils/shutdown-coordinator.js +1 -1
  178. package/src/utils/trusted-keys.js +1 -1
  179. package/src/utils/upload-handler.js +1 -1
  180. package/src/utils/upsert-builder.js +1 -1
  181. package/src/utils/workflow-hook-executor.js +1 -1
@@ -93,6 +93,456 @@ if (isCompiledBinary) {
93
93
  process.removeAllListeners('warning');
94
94
  }
95
95
 
96
+ // ============================================================================
97
+ // FOREIGN KEY EXPANSION (payload sync --expand-fk)
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Parse string --fk-columns menjadi array spec {table, column, raw}.
102
+ *
103
+ * Kontrak: setiap entri WAJIB berbentuk `table.column` (qualified), dipisah
104
+ * koma. Bentuk polos (tanpa `table.`) ditolak agar tidak ada penebakan tabel
105
+ * referensi pada kasus multi-FK. Entri duplikat (table.column sama,
106
+ * case-insensitive) di-dedupe dengan mempertahankan urutan kemunculan pertama.
107
+ *
108
+ * @param {string} fkColumnsStr - Nilai flag --fk-columns
109
+ * @returns {Array<{table: string, column: string, raw: string}>}
110
+ * @throws {Error} bila kosong atau ada entri yang tidak berbentuk table.column
111
+ */
112
+ function parseFkColumns(fkColumnsStr) {
113
+ if (typeof fkColumnsStr !== 'string' || fkColumnsStr.trim().length === 0) {
114
+ throw new Error(
115
+ '--fk-columns is required when --expand-fk is set ' +
116
+ '(format: table.column,table.column)'
117
+ );
118
+ }
119
+
120
+ const entries = fkColumnsStr.split(',').map(s => s.trim()).filter(Boolean);
121
+ if (entries.length === 0) {
122
+ throw new Error('--fk-columns has no usable entries after parsing');
123
+ }
124
+
125
+ const specs = [];
126
+ const seen = new Set();
127
+ for (const raw of entries) {
128
+ const parts = raw.split('.');
129
+ if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
130
+ throw new Error(
131
+ `Invalid --fk-columns entry "${raw}": each entry must be qualified as table.column`
132
+ );
133
+ }
134
+ const table = parts[0].trim();
135
+ const column = parts[1].trim();
136
+ const key = `${table.toLowerCase()}.${column.toLowerCase()}`;
137
+ if (seen.has(key)) continue;
138
+ seen.add(key);
139
+ specs.push({ table, column, raw });
140
+ }
141
+ return specs;
142
+ }
143
+
144
+ /**
145
+ * Derive alias tabel secara deterministik: inisial tiap kata yang dipisah
146
+ * underscore, lowercase. Contoh: `visitors`->`v`, `visitor_categories`->`vc`,
147
+ * `stock_inbound`->`si`. Schema prefix (mis. `public.`) diabaikan.
148
+ *
149
+ * @param {string} tableName
150
+ * @returns {string}
151
+ */
152
+ function deriveTableAlias(tableName) {
153
+ const bare = tableName.includes('.') ? tableName.split('.').pop() : tableName;
154
+ const parts = String(bare).split('_').filter(Boolean);
155
+ const initials = parts.map(p => p[0]).join('').toLowerCase();
156
+ return initials || 't';
157
+ }
158
+
159
+ /**
160
+ * Pilih kolom display dari tabel referensi secara heuristik untuk mode
161
+ * auto-resolve (`--expand-fk` tanpa `--fk-columns`).
162
+ *
163
+ * Urutan prioritas (deterministik):
164
+ * 1. Kolom "name" : token `name` / `nama`
165
+ * 2. Kolom "code" : token `code` / `kode`
166
+ * 3. Primary key tabel referensi (fallback terakhir)
167
+ *
168
+ * Dalam tiap kelompok token, skor: exact match (3) > berakhiran `_<token>` (2)
169
+ * > mengandung token (1). Skor tertinggi menang; bila seri, kolom yang muncul
170
+ * lebih dulu di urutan kolom database dipertahankan. Kolom audit default
171
+ * di-exclude dari kandidat name/code.
172
+ *
173
+ * @param {string[]} refColumns - Kolom fisik tabel referensi (urutan database)
174
+ * @param {string|null} primaryKey - Primary key tabel referensi
175
+ * @returns {string|null} Nama kolom terpilih, atau null bila tidak ada kandidat
176
+ */
177
+ function pickDisplayColumn(refColumns, primaryKey) {
178
+ const cols = (refColumns || []).filter(c => !DEFAULT_AUDIT_COLUMNS.includes(c));
179
+
180
+ const bestByTokens = (tokens) => {
181
+ let best = null;
182
+ let bestScore = 0;
183
+ for (const c of cols) {
184
+ const lc = String(c).toLowerCase();
185
+ let score = 0;
186
+ for (const t of tokens) {
187
+ if (lc === t) score = Math.max(score, 3);
188
+ else if (lc.endsWith(`_${t}`)) score = Math.max(score, 2);
189
+ else if (lc.includes(t)) score = Math.max(score, 1);
190
+ }
191
+ if (score > bestScore) {
192
+ bestScore = score;
193
+ best = c;
194
+ }
195
+ }
196
+ return best;
197
+ };
198
+
199
+ return bestByTokens(['name', 'nama'])
200
+ || bestByTokens(['code', 'kode'])
201
+ || primaryKey
202
+ || null;
203
+ }
204
+
205
+ /**
206
+ * Bangun konfigurasi JOIN dari foreign key. Pure (tanpa I/O) agar mudah di-test.
207
+ * Mengembalikan { updatedPayload, sqlRelPath, sqlContent } atau throw Error pada
208
+ * kondisi invalid (tabel referensi tidak ada, kolom tidak ditemukan, FK ganda
209
+ * ke tabel sama, collision output yang tidak dapat di-resolve).
210
+ *
211
+ * Aturan output:
212
+ * - SELECT base = payload.fieldName yang merupakan kolom fisik base table
213
+ * (kolom non-fisik mis. hasil JOIN run sebelumnya dibuang agar idempotent)
214
+ * - kolom referensi di-surface dengan prefix alias; bila nama output bentrok
215
+ * (dengan kolom base atau kolom referensi lain), output di-prefix nama tabel
216
+ * referensi via `AS` (mis. `w.city AS warehouse_city`)
217
+ * - LEFT JOIN per tabel referensi yang dipakai, kondisi diturunkan dari FK
218
+ * - datatablesQuery & viewQuery -> `file:query/<table>-join.sql`
219
+ * - datatablesWhere: pertahankan existing, hapus output lama, sisipkan output
220
+ * referensi sebelum entri `"all"`
221
+ * - fieldValidation TIDAK disentuh (kolom JOIN bukan kolom fisik)
222
+ *
223
+ * @param {Object} payload - Payload saat ini (punya tableName + fieldName)
224
+ * @param {Array<{table,column,raw}>} fkSpec - hasil parseFkColumns
225
+ * @param {Array} foreignKeys - hasil db.getForeignKeys(tableName)
226
+ * @param {Object} refColumnsMap - { refTableLower: string[] kolom fisik referensi }
227
+ * @param {string[]} baseColumns - kolom fisik base table (non-audit)
228
+ * @returns {{updatedPayload: Object, sqlRelPath: string, sqlContent: string}}
229
+ */
230
+ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns) {
231
+ const tableName = payload.tableName;
232
+
233
+ // Base fields = fieldName yang benar-benar kolom fisik base table.
234
+ const basePhysicalLower = new Set((baseColumns || []).map(c => String(c).toLowerCase()));
235
+ const declaredFields = Array.isArray(payload.fieldName) ? payload.fieldName : [];
236
+ const baseFields = declaredFields.filter(f => basePhysicalLower.has(String(f).toLowerCase()));
237
+
238
+ // Map refTable(lower) -> FK; deteksi FK ganda ke tabel referensi yang sama.
239
+ const fkByRefTable = new Map();
240
+ const dupRefTables = new Set();
241
+ for (const fk of (foreignKeys || [])) {
242
+ const rt = fk && fk.references && fk.references.table ? String(fk.references.table) : '';
243
+ if (!rt) continue;
244
+ const key = rt.toLowerCase();
245
+ if (fkByRefTable.has(key)) dupRefTables.add(key);
246
+ else fkByRefTable.set(key, fk);
247
+ }
248
+
249
+ if (fkByRefTable.size === 0) {
250
+ throw new Error(
251
+ `Table "${tableName}" has no foreign keys; --expand-fk cannot resolve any reference`
252
+ );
253
+ }
254
+
255
+ // Alias unik: base dulu, lalu tiap tabel referensi sesuai urutan kemunculan.
256
+ const usedAliases = new Set();
257
+ const uniqueAlias = (base) => {
258
+ let a = base, n = 1;
259
+ while (usedAliases.has(a)) { n++; a = base + n; }
260
+ usedAliases.add(a);
261
+ return a;
262
+ };
263
+ const baseAlias = uniqueAlias(deriveTableAlias(tableName));
264
+
265
+ const refTableAlias = new Map(); // refKeyLower -> { alias, fk, refTableName }
266
+ const refSelections = []; // { alias, column, refTableName }
267
+ for (const spec of fkSpec) {
268
+ const key = spec.table.toLowerCase();
269
+ if (!fkByRefTable.has(key)) {
270
+ const available = [...fkByRefTable.keys()].join(', ') || '(none)';
271
+ throw new Error(
272
+ `Foreign key reference table "${spec.table}" not found for "${tableName}". ` +
273
+ `Referenced tables available: ${available}`
274
+ );
275
+ }
276
+ if (dupRefTables.has(key)) {
277
+ throw new Error(
278
+ `Table "${tableName}" has multiple foreign keys referencing "${spec.table}"; ` +
279
+ 'cannot disambiguate by table name'
280
+ );
281
+ }
282
+
283
+ const fk = fkByRefTable.get(key);
284
+ const refTableName = fk.references.table;
285
+ const refCols = refColumnsMap[key] || refColumnsMap[refTableName] || [];
286
+ const refColsLower = refCols.map(c => String(c).toLowerCase());
287
+ if (!refColsLower.includes(spec.column.toLowerCase())) {
288
+ throw new Error(
289
+ `Column "${spec.column}" not found in referenced table "${spec.table}". ` +
290
+ `Available columns: ${refCols.join(', ') || '(none)'}`
291
+ );
292
+ }
293
+
294
+ if (!refTableAlias.has(key)) {
295
+ refTableAlias.set(key, {
296
+ alias: uniqueAlias(deriveTableAlias(refTableName)),
297
+ fk,
298
+ refTableName
299
+ });
300
+ }
301
+ refSelections.push({ alias: refTableAlias.get(key).alias, column: spec.column, refTableName });
302
+ }
303
+
304
+ // Output name + collision auto-prefix. Hitung occurrence nama tentatif
305
+ // (base fields + kolom referensi apa adanya); nama yang muncul >1 kali
306
+ // di-prefix nama tabel referensi.
307
+ const nameCount = new Map();
308
+ const bump = (n) => nameCount.set(n, (nameCount.get(n) || 0) + 1);
309
+ for (const f of baseFields) bump(f);
310
+ for (const r of refSelections) bump(r.column);
311
+
312
+ const finalNames = new Set(baseFields);
313
+ const refOutputs = []; // { alias, column, output, refTableName }
314
+ for (const r of refSelections) {
315
+ let output = r.column;
316
+ if ((nameCount.get(r.column) || 0) > 1) {
317
+ output = `${r.refTableName}_${r.column}`.toLowerCase();
318
+ }
319
+ if (finalNames.has(output)) {
320
+ throw new Error(
321
+ `Output column "${output}" collides after auto-prefix in expansion for "${tableName}"; ` +
322
+ 'rename the source column or adjust the schema'
323
+ );
324
+ }
325
+ finalNames.add(output);
326
+ refOutputs.push({ alias: r.alias, column: r.column, output, refTableName: r.refTableName });
327
+ }
328
+
329
+ // SQL build.
330
+ const baseSelect = baseFields.map(c => `${baseAlias}.${c}`);
331
+ const refSelect = refOutputs.map(r =>
332
+ r.output === r.column ? `${r.alias}.${r.column}` : `${r.alias}.${r.column} AS ${r.output}`
333
+ );
334
+ const selectItems = [...baseSelect, ...refSelect];
335
+
336
+ const sqlLines = selectItems.map((item, idx) => {
337
+ const prefix = idx === 0 ? 'SELECT ' : ' ';
338
+ const suffix = idx === selectItems.length - 1 ? '' : ',';
339
+ return `${prefix}${item}${suffix}`;
340
+ });
341
+ sqlLines.push(`FROM ${tableName} ${baseAlias}`);
342
+ for (const [, info] of refTableAlias) {
343
+ const localCols = info.fk.columns || [];
344
+ const refCols = (info.fk.references && info.fk.references.columns) || [];
345
+ const pairCount = Math.max(localCols.length, refCols.length);
346
+ const conds = [];
347
+ for (let i = 0; i < pairCount; i++) {
348
+ conds.push(`${info.alias}.${refCols[i]} = ${baseAlias}.${localCols[i]}`);
349
+ }
350
+ sqlLines.push(`LEFT JOIN ${info.refTableName} ${info.alias} ON ${conds.join(' AND ')}`);
351
+ }
352
+ const sqlContent = `${sqlLines.join('\n')}\n`;
353
+
354
+ // Mutasi payload.
355
+ const baseFilename = tableName.replace(/[._]/g, '-');
356
+ const sqlRelPath = `query/${baseFilename}-join.sql`;
357
+ const refOutputNames = refOutputs.map(r => r.output);
358
+
359
+ const updatedPayload = { ...payload };
360
+ updatedPayload.fieldName = [
361
+ ...baseFields,
362
+ ...refOutputNames
363
+ ];
364
+ updatedPayload.datatablesQuery = `file:${sqlRelPath}`;
365
+ updatedPayload.viewQuery = `file:${sqlRelPath}`;
366
+
367
+ let where = Array.isArray(updatedPayload.datatablesWhere)
368
+ ? updatedPayload.datatablesWhere.slice()
369
+ : [];
370
+ where = where.filter(w => !refOutputNames.includes(w));
371
+ const allIdx = where.indexOf('all');
372
+ const insertAt = allIdx >= 0 ? allIdx : where.length;
373
+ where.splice(insertAt, 0, ...refOutputNames);
374
+ updatedPayload.datatablesWhere = where;
375
+
376
+ return { updatedPayload, sqlRelPath, sqlContent };
377
+ }
378
+
379
+ // ============================================================================
380
+ // SEARCHABLE COLUMNS (datatablesWhere)
381
+ // ============================================================================
382
+
383
+ // Pola nama kolom yang tidak dijadikan searchable secara default (id + audit/
384
+ // timestamp). Dipakai hanya untuk MEMILIH kolom default, bukan sebagai aturan
385
+ // keras tentang kolom apa yang boleh ada di datatablesWhere.
386
+ const SEARCHABLE_EXCLUDE_PATTERNS = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
387
+
388
+ // Tipe data string yang valid untuk pencarian LIKE.
389
+ const SEARCHABLE_STRING_DATA_TYPES = ['character varying', 'varchar', 'text', 'character', 'char'];
390
+ const SEARCHABLE_STRING_UDT = ['varchar', 'text', 'bpchar'];
391
+
392
+ /**
393
+ * Kumpulkan nama kolom bertipe string (varchar/text/char) dari detailedColumns.
394
+ *
395
+ * detailedColumns sebaiknya sudah di-enrich dengan boolean
396
+ * (`enrichDetailedColumnsWithBoolean`) agar kolom boolean-marker pada dialect
397
+ * varchar (mysql/oracle/sqlite) ter-set `data_type='boolean'` dan TIDAK salah
398
+ * dihitung sebagai string. Hanya kolom bertipe string yang boleh masuk
399
+ * `datatablesWhere`, sebab runtime membungkus kolom searchable dengan
400
+ * `upper(col) LIKE upper(?)` yang gagal pada tipe non-text (mis. PostgreSQL
401
+ * `function upper(integer) does not exist`).
402
+ *
403
+ * @param {Array} detailedColumns - Array dari getDetailedColumnInfo() (enriched)
404
+ * @returns {Set<string>} nama kolom bertipe string
405
+ */
406
+ function collectStringColumns(detailedColumns) {
407
+ const set = new Set();
408
+ for (const col of (detailedColumns || [])) {
409
+ const dt = String(col.data_type || '').toLowerCase();
410
+ const udt = String(col.udt_name || '').toLowerCase();
411
+ if (SEARCHABLE_STRING_DATA_TYPES.includes(dt) || SEARCHABLE_STRING_UDT.includes(udt)) {
412
+ set.add(col.column_name);
413
+ }
414
+ }
415
+ return set;
416
+ }
417
+
418
+ /**
419
+ * Predikat default untuk pemilihan kolom searchable baru: kolom tidak cocok pola
420
+ * exclude nama (id/audit) DAN bertipe string.
421
+ *
422
+ * @param {string} fieldName
423
+ * @param {Set<string>} stringColumns - hasil collectStringColumns
424
+ * @returns {boolean}
425
+ */
426
+ function isDefaultSearchableColumn(fieldName, stringColumns) {
427
+ const lower = String(fieldName).toLowerCase();
428
+ if (SEARCHABLE_EXCLUDE_PATTERNS.some(p => lower.includes(p))) return false;
429
+ return stringColumns.has(fieldName);
430
+ }
431
+
432
+ // ============================================================================
433
+ // DEFAULT SCOPE (is_active) — built-in untuk payload generate & sync
434
+ // ============================================================================
435
+
436
+ // Nama kolom penanda aktif. Match exact lowercase (snake_case), konsisten dengan
437
+ // konvensi penamaan RESTForge.
438
+ const IS_ACTIVE_COLUMN = 'is_active';
439
+
440
+ // Action yang menerima filter is_active otomatis (sesuai catalogs/rdf/default-scope.md;
441
+ // datatables & first sengaja tidak terpengaruh).
442
+ const DEFAULT_SCOPE_ACTIONS = ['lookup', 'read'];
443
+
444
+ /**
445
+ * Terapkan/lepas defaultScope berbasis kolom `is_active` secara in-place.
446
+ *
447
+ * - hasIsActive true : pastikan defaultScope.lookup.is_active = true dan
448
+ * defaultScope.read.is_active = true (merge, pertahankan key scope lain).
449
+ * - hasIsActive false : hapus key `is_active` dari lookup/read; bersihkan object
450
+ * action yang menjadi kosong; hapus `defaultScope` bila kosong total.
451
+ *
452
+ * Hanya menyentuh key `is_active` agar kustomisasi scope lain (mis. tenant_id)
453
+ * tidak ikut hilang. Pemanggil menentukan `hasIsActive` dari keberadaan kolom
454
+ * `is_active` di fieldName payload (sekaligus menjamin aturan validator
455
+ * "kolom defaultScope harus ada di fieldName" terpenuhi).
456
+ *
457
+ * @param {Object} payload - Payload yang dimutasi
458
+ * @param {boolean} hasIsActive - Apakah kolom is_active ada di fieldName
459
+ * @returns {void}
460
+ */
461
+ function applyIsActiveDefaultScope(payload, hasIsActive) {
462
+ if (hasIsActive) {
463
+ const existing = (payload.defaultScope && typeof payload.defaultScope === 'object'
464
+ && !Array.isArray(payload.defaultScope))
465
+ ? payload.defaultScope
466
+ : {};
467
+ for (const action of DEFAULT_SCOPE_ACTIONS) {
468
+ const prev = (existing[action] && typeof existing[action] === 'object'
469
+ && !Array.isArray(existing[action]))
470
+ ? existing[action]
471
+ : {};
472
+ existing[action] = { ...prev, [IS_ACTIVE_COLUMN]: true };
473
+ }
474
+ payload.defaultScope = existing;
475
+ return;
476
+ }
477
+
478
+ // hasIsActive false -> lepas key is_active.
479
+ const ds = payload.defaultScope;
480
+ if (!ds || typeof ds !== 'object' || Array.isArray(ds)) return;
481
+ for (const action of DEFAULT_SCOPE_ACTIONS) {
482
+ if (ds[action] && typeof ds[action] === 'object' && !Array.isArray(ds[action])) {
483
+ delete ds[action][IS_ACTIVE_COLUMN];
484
+ if (Object.keys(ds[action]).length === 0) delete ds[action];
485
+ }
486
+ }
487
+ if (Object.keys(ds).length === 0) delete payload.defaultScope;
488
+ }
489
+
490
+ // ============================================================================
491
+ // ADVISORIES (informational) — untuk payload validate
492
+ // ============================================================================
493
+
494
+ /**
495
+ * Bangun daftar advisory informational untuk sebuah payload. Advisory bersifat
496
+ * SARAN tindak lanjut, BUKAN error: tidak memengaruhi verdict (OK/DRIFT/ERROR)
497
+ * maupun exit code.
498
+ *
499
+ * Jenis advisory:
500
+ * - fk : tabel punya foreign key tetapi payload belum men-surface-nya
501
+ * sebagai JOIN (tidak ada `viewQuery`). Saran: `payload sync --expand-fk`.
502
+ * - scope : kolom `is_active` ada di fieldName tetapi `defaultScope` belum
503
+ * memuat `is_active` di lookup & read. Saran: `payload sync`.
504
+ *
505
+ * @param {Object} payload
506
+ * @param {Object} info
507
+ * @param {Array} info.foreignKeys - hasil db.getForeignKeys(tableName)
508
+ * @param {boolean} info.hasIsActive - apakah is_active ada di fieldName
509
+ * @returns {Array<{type: string, table: string, message: string, command: string}>}
510
+ */
511
+ function buildPayloadAdvisories(payload, info) {
512
+ const advisories = [];
513
+ const table = payload.tableName;
514
+
515
+ // a. Foreign key belum di-surface sebagai JOIN.
516
+ const fkCount = Array.isArray(info.foreignKeys) ? info.foreignKeys.length : 0;
517
+ const usesJoin = typeof payload.viewQuery === 'string' && payload.viewQuery.trim().length > 0;
518
+ if (fkCount > 0 && !usesJoin) {
519
+ advisories.push({
520
+ type: 'fk',
521
+ table,
522
+ message: `has ${fkCount} foreign key(s) not surfaced as JOIN`,
523
+ command: `npx restforge payload sync --table=${table} --expand-fk`
524
+ });
525
+ }
526
+
527
+ // b. is_active ada tetapi defaultScope belum lengkap.
528
+ if (info.hasIsActive) {
529
+ const ds = payload.defaultScope;
530
+ const scopeComplete = ds && typeof ds === 'object' && !Array.isArray(ds)
531
+ && ds.lookup && ds.lookup[IS_ACTIVE_COLUMN] === true
532
+ && ds.read && ds.read[IS_ACTIVE_COLUMN] === true;
533
+ if (!scopeComplete) {
534
+ advisories.push({
535
+ type: 'scope',
536
+ table,
537
+ message: 'has is_active but defaultScope is incomplete',
538
+ command: `npx restforge payload sync --table=${table}`
539
+ });
540
+ }
541
+ }
542
+
543
+ return advisories;
544
+ }
545
+
96
546
 
97
547
  /**
98
548
  * Class untuk Payload Generator (non-interactive only)
@@ -576,7 +1026,10 @@ class PayloadGenerator {
576
1026
 
577
1027
  let result;
578
1028
  if (args.sync) {
579
- result = await validator.runSync(targetTable, this);
1029
+ result = await validator.runSync(targetTable, this, {
1030
+ expandFk: args.expandFk === true,
1031
+ fkColumns: args.fkColumns || null
1032
+ });
580
1033
  } else if (args.diff) {
581
1034
  result = await validator.runDiff(targetTable);
582
1035
  } else {
@@ -699,12 +1152,33 @@ class PayloadGenerator {
699
1152
  const sqlContent = `${sqlLines.join('\n')}\nFROM ${payloadData.tableName} a\n`;
700
1153
  payloadData.datatablesQuery = `file:query/${sqlFilename}`;
701
1154
 
702
- // Generate searchable columns
703
- const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
704
- payloadData.datatablesWhere = payloadData.fieldName.filter(field => {
705
- const fieldLower = field.toLowerCase();
706
- return !excludePatterns.some(pattern => fieldLower.includes(pattern));
707
- });
1155
+ // Introspeksi tipe & constraint sekali, dipakai untuk searchable columns,
1156
+ // dateTimeFields, fieldValidation, dan uniqueConstraints.
1157
+ let enrichedColumns = null;
1158
+ let columnTypes = null;
1159
+ let constraints = [];
1160
+ if (this.db.pool) {
1161
+ columnTypes = await this.db.getColumnTypes(args.table);
1162
+ const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
1163
+ constraints = await this.db.getConstraints(args.table);
1164
+ const booleanColumns = typeof this.db.getBooleanColumns === 'function'
1165
+ ? await this.db.getBooleanColumns(args.table)
1166
+ : [];
1167
+ enrichedColumns = this.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
1168
+ enrichedColumns = this.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
1169
+ }
1170
+
1171
+ // Generate searchable columns: HANYA kolom bertipe string yang masuk
1172
+ // datatablesWhere (lihat collectStringColumns). Kolom non-string
1173
+ // (integer/numeric/boolean/date/timestamp) di-exclude agar runtime tidak
1174
+ // membentuk upper(col) LIKE upper(?) pada tipe non-text. Pola nama id/audit
1175
+ // tetap di-exclude. Fallback name-only dipakai bila DB tidak terkoneksi.
1176
+ const stringColumns = enrichedColumns ? collectStringColumns(enrichedColumns) : null;
1177
+ payloadData.datatablesWhere = payloadData.fieldName.filter(field =>
1178
+ stringColumns
1179
+ ? isDefaultSearchableColumn(field, stringColumns)
1180
+ : !SEARCHABLE_EXCLUDE_PATTERNS.some(p => field.toLowerCase().includes(p))
1181
+ );
708
1182
  payloadData.datatablesWhere.push('all');
709
1183
 
710
1184
  // Enable all actions
@@ -718,22 +1192,18 @@ class PayloadGenerator {
718
1192
  read: true
719
1193
  };
720
1194
 
1195
+ // Built-in defaultScope: bila tabel punya kolom is_active, tambahkan filter
1196
+ // { is_active: true } untuk action lookup & read (lihat catalogs/rdf/default-scope.md).
1197
+ applyIsActiveDefaultScope(payloadData, payloadData.fieldName.includes(IS_ACTIVE_COLUMN));
1198
+
721
1199
  // Generate dateTimeFields and fieldValidation
722
- if (this.db.pool) {
723
- const columnTypes = await this.db.getColumnTypes(args.table);
1200
+ if (enrichedColumns) {
724
1201
  const dateTimeFields = this.generateDateTimeFields(columnTypes, payloadData.fieldName);
725
1202
  if (Object.keys(dateTimeFields).length > 0) {
726
1203
  payloadData.dateTimeFields = dateTimeFields;
727
1204
  }
728
1205
 
729
1206
  // Generate fieldValidation for special fields
730
- const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
731
- const constraints = await this.db.getConstraints(args.table);
732
- const booleanColumns = typeof this.db.getBooleanColumns === 'function'
733
- ? await this.db.getBooleanColumns(args.table)
734
- : [];
735
- let enrichedColumns = this.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
736
- enrichedColumns = this.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
737
1207
  const fieldValidation = this.generateFieldValidation(enrichedColumns, payloadData.fieldName, payloadData.primaryKey);
738
1208
  if (fieldValidation.length > 0) {
739
1209
  payloadData.fieldValidation = fieldValidation;
@@ -947,6 +1417,7 @@ class SchemaValidator {
947
1417
  console.log();
948
1418
 
949
1419
  const results = [];
1420
+ const advisories = [];
950
1421
  let ok = 0, drift = 0, error = 0;
951
1422
 
952
1423
  for (const { fileName, payload } of payloads) {
@@ -954,6 +1425,20 @@ class SchemaValidator {
954
1425
  results.push({ fileName, comparison });
955
1426
  this.printComparisonResult(comparison, fileName, false);
956
1427
 
1428
+ // Kumpulkan advisory informational (tidak memengaruhi verdict/exit code).
1429
+ // Dilewati bila tabel tidak ada di database (status error).
1430
+ if (comparison.status !== 'error') {
1431
+ let foreignKeys = [];
1432
+ if (typeof this.db.getForeignKeys === 'function') {
1433
+ try {
1434
+ foreignKeys = await this.db.getForeignKeys(payload.tableName);
1435
+ } catch (_e) { foreignKeys = []; }
1436
+ }
1437
+ const hasIsActive = Array.isArray(payload.fieldName)
1438
+ && payload.fieldName.includes(IS_ACTIVE_COLUMN);
1439
+ advisories.push(...buildPayloadAdvisories(payload, { foreignKeys, hasIsActive }));
1440
+ }
1441
+
957
1442
  if (comparison.status === 'ok') ok++;
958
1443
  else if (comparison.status === 'drift') drift++;
959
1444
  else error++;
@@ -970,9 +1455,19 @@ class SchemaValidator {
970
1455
  if (error > 0) console.log(` Error : ${error}`);
971
1456
  console.log();
972
1457
 
1458
+ // Advisories informational (saran tindak lanjut FK & scope), bukan error.
1459
+ if (advisories.length > 0) {
1460
+ console.log(' Advisories (informational, not errors):');
1461
+ for (const a of advisories) {
1462
+ console.log(` [i] ${a.table}: ${a.message}`);
1463
+ console.log(` -> ${a.command}`);
1464
+ }
1465
+ console.log();
1466
+ }
1467
+
973
1468
  if (drift > 0 || error > 0) {
974
- console.log(' Use --diff for detailed comparison.');
975
- console.log(' Use --sync to update payload files automatically.');
1469
+ console.log(" Use 'npx restforge payload diff' for detailed comparison.");
1470
+ console.log(" Use 'npx restforge payload sync' to update payload files automatically.");
976
1471
  console.log();
977
1472
  }
978
1473
 
@@ -1042,7 +1537,7 @@ class SchemaValidator {
1042
1537
  console.log();
1043
1538
 
1044
1539
  if (drift > 0) {
1045
- console.log(' Use --sync to update payload files automatically.');
1540
+ console.log(" Use 'npx restforge payload sync' to update payload files automatically.");
1046
1541
  console.log();
1047
1542
  }
1048
1543
 
@@ -1091,15 +1586,43 @@ class SchemaValidator {
1091
1586
  * File lama di-archive sebelum overwrite.
1092
1587
  * @param {string|null} tableName - Nama table spesifik, atau null untuk semua
1093
1588
  * @param {PayloadGenerator} generator - Instance PayloadGenerator untuk re-generate
1589
+ * @param {Object} [options]
1590
+ * @param {boolean} [options.expandFk] - Aktifkan FK JOIN expansion
1591
+ * @param {string} [options.fkColumns] - Daftar kolom referensi `table.column,...`
1094
1592
  * @returns {Promise<Object>} { total, synced, skipped, error }
1095
1593
  */
1096
- async runSync(tableName, generator) {
1594
+ async runSync(tableName, generator, options = {}) {
1595
+ const expandFk = options.expandFk === true;
1596
+ const fkColumns = options.fkColumns || null;
1597
+
1097
1598
  console.log();
1098
1599
  console.log('='.repeat(60));
1099
1600
  console.log('SCHEMA SYNC - Update Payload from Database');
1100
1601
  console.log('='.repeat(60));
1101
1602
  console.log();
1102
1603
 
1604
+ // Mode expand-fk butuh target tabel tunggal dan --fk-columns yang valid.
1605
+ // Validasi upfront agar gagal cepat dengan pesan jelas.
1606
+ if (expandFk) {
1607
+ if (!tableName) {
1608
+ console.log(' [ERROR] --expand-fk requires --table=<table>');
1609
+ console.log();
1610
+ return { total: 0, synced: 0, skipped: 0, error: 1 };
1611
+ }
1612
+ // --fk-columns bersifat opsional: bila diisi, validasi format upfront;
1613
+ // bila kosong, mode auto-resolve (kolom display dipilih per FK saat
1614
+ // applyForeignKeyExpansion).
1615
+ if (fkColumns) {
1616
+ try {
1617
+ parseFkColumns(fkColumns);
1618
+ } catch (e) {
1619
+ console.log(` [ERROR] ${e.message}`);
1620
+ console.log();
1621
+ return { total: 0, synced: 0, skipped: 0, error: 1 };
1622
+ }
1623
+ }
1624
+ }
1625
+
1103
1626
  let payloads;
1104
1627
  if (tableName) {
1105
1628
  const found = this.findPayloadForTable(tableName);
@@ -1136,6 +1659,53 @@ class SchemaValidator {
1136
1659
  continue;
1137
1660
  }
1138
1661
 
1662
+ // Mode expand-fk: terapkan konfigurasi JOIN dari foreign key. Berbeda
1663
+ // dari sync biasa, mode ini tetap memproses meski kolom fisik sudah
1664
+ // in-sync (status 'ok'); bila ada drift kolom fisik, rekonsiliasi dulu
1665
+ // lewat regeneratePayload baru ekspansi FK diterapkan di atasnya.
1666
+ if (expandFk) {
1667
+ let workingPayload = payload;
1668
+ if (comparison.status === 'drift') {
1669
+ try {
1670
+ workingPayload = await this.regeneratePayload(payload, comparison, { fileName });
1671
+ } catch (e) {
1672
+ console.log(` [ERROR] ${fileName} - physical reconcile failed: ${e.message}`);
1673
+ errorCount++;
1674
+ continue;
1675
+ }
1676
+ }
1677
+
1678
+ let expansion;
1679
+ try {
1680
+ expansion = await this.applyForeignKeyExpansion(workingPayload, fkColumns, { fileName });
1681
+ } catch (e) {
1682
+ console.log(` [ERROR] ${fileName} - expand-fk failed: ${e.message}`);
1683
+ errorCount++;
1684
+ continue;
1685
+ }
1686
+
1687
+ const archivePath = this.archiveFile(filePath);
1688
+ console.log(` [ARCHIVE] ${fileName} -> ${path.basename(archivePath)}`);
1689
+ try {
1690
+ // Tulis file SQL JOIN.
1691
+ const sqlAbsPath = path.join(this.outputDir, expansion.sqlRelPath);
1692
+ const sqlDir = path.dirname(sqlAbsPath);
1693
+ if (!fs.existsSync(sqlDir)) fs.mkdirSync(sqlDir, { recursive: true });
1694
+ fs.writeFileSync(sqlAbsPath, expansion.sqlContent, 'utf8');
1695
+
1696
+ fs.writeFileSync(filePath, JSON.stringify(expansion.updatedPayload, null, 4), 'utf8');
1697
+ console.log(` [QUERY] ${path.basename(sqlAbsPath)} written (${expansion.sqlRelPath})`);
1698
+ console.log(` [SYNCED] ${fileName} - FK expansion applied (${expansion.updatedPayload.fieldName.length} fields)`);
1699
+ synced++;
1700
+ } catch (e) {
1701
+ // Restore payload lama dari archive jika gagal menulis.
1702
+ fs.renameSync(archivePath, filePath);
1703
+ console.log(` [ERROR] ${fileName} - sync failed: ${e.message}`);
1704
+ errorCount++;
1705
+ }
1706
+ continue;
1707
+ }
1708
+
1139
1709
  if (comparison.status === 'ok') {
1140
1710
  console.log(` [SKIP] ${fileName} - already in sync`);
1141
1711
  skipped++;
@@ -1177,6 +1747,86 @@ class SchemaValidator {
1177
1747
  return { total: payloads.length, synced, skipped, error: errorCount };
1178
1748
  }
1179
1749
 
1750
+ /**
1751
+ * Terapkan FK JOIN expansion pada sebuah payload. Orkestrasi I/O
1752
+ * (introspeksi FK + kolom tabel referensi) lalu delegasi ke pure builder
1753
+ * `buildForeignKeyExpansion`. Tidak menulis file; caller (runSync) yang
1754
+ * menulis SQL + payload.
1755
+ *
1756
+ * Mode kolom:
1757
+ * - Eksplisit: `fkColumnsStr` diisi (`table.column,...`) -> dipakai apa adanya.
1758
+ * - Auto-resolve: `fkColumnsStr` kosong/null -> untuk SETIAP foreign key,
1759
+ * pilih satu kolom display dari tabel referensi via `pickDisplayColumn`
1760
+ * (heuristik name/nama -> code/kode -> primary key).
1761
+ *
1762
+ * @param {Object} payload - Payload saat ini (sudah direkonsiliasi kolom fisik bila perlu)
1763
+ * @param {string|null} fkColumnsStr - Nilai --fk-columns (`table.column,...`) atau null untuk auto
1764
+ * @param {Object} [ctx]
1765
+ * @param {string} [ctx.fileName] - Nama file payload untuk konteks pesan
1766
+ * @returns {Promise<{updatedPayload: Object, sqlRelPath: string, sqlContent: string}>}
1767
+ * @throws {Error} bila introspeksi/validasi gagal
1768
+ */
1769
+ async applyForeignKeyExpansion(payload, fkColumnsStr, ctx = {}) {
1770
+ const tableName = payload.tableName;
1771
+
1772
+ const foreignKeys = typeof this.db.getForeignKeys === 'function'
1773
+ ? await this.db.getForeignKeys(tableName)
1774
+ : [];
1775
+ if (!Array.isArray(foreignKeys) || foreignKeys.length === 0) {
1776
+ throw new Error(
1777
+ `Table "${tableName}" has no foreign keys; --expand-fk cannot resolve any reference`
1778
+ );
1779
+ }
1780
+
1781
+ // Kolom fisik base table (buang kolom audit default agar tidak masuk SELECT base).
1782
+ const baseColumns = (await this.db.getColumns(tableName))
1783
+ .filter(c => !DEFAULT_AUDIT_COLUMNS.includes(c));
1784
+
1785
+ const isAuto = typeof fkColumnsStr !== 'string' || fkColumnsStr.trim().length === 0;
1786
+ const refColumnsMap = {};
1787
+ let fkSpec;
1788
+
1789
+ if (isAuto) {
1790
+ // Auto-resolve: satu kolom display per foreign key.
1791
+ fkSpec = [];
1792
+ for (const fk of foreignKeys) {
1793
+ const rt = fk && fk.references && fk.references.table;
1794
+ if (!rt) continue;
1795
+ const key = String(rt).toLowerCase();
1796
+ if (!refColumnsMap[key]) {
1797
+ refColumnsMap[key] = await this.db.getColumns(rt);
1798
+ }
1799
+ const refPk = typeof this.db.getPrimaryKey === 'function'
1800
+ ? await this.db.getPrimaryKey(rt)
1801
+ : null;
1802
+ const display = pickDisplayColumn(refColumnsMap[key], refPk);
1803
+ if (!display) {
1804
+ throw new Error(
1805
+ `Could not auto-resolve a display column for referenced table "${rt}" ` +
1806
+ '(no name/code column and no primary key found). Provide --fk-columns explicitly.'
1807
+ );
1808
+ }
1809
+ fkSpec.push({ table: rt, column: display, raw: `${rt}.${display}` });
1810
+ console.log(` [AUTO] ${tableName} -> ${rt}.${display} (display column)`);
1811
+ }
1812
+ } else {
1813
+ // Eksplisit: parse spec lalu fetch kolom tabel referensi yang disebut.
1814
+ fkSpec = parseFkColumns(fkColumnsStr);
1815
+ const neededTables = new Set(fkSpec.map(s => s.table.toLowerCase()));
1816
+ for (const fk of foreignKeys) {
1817
+ const rt = fk && fk.references && fk.references.table;
1818
+ if (!rt) continue;
1819
+ const key = String(rt).toLowerCase();
1820
+ if (!neededTables.has(key)) continue;
1821
+ if (!refColumnsMap[key]) {
1822
+ refColumnsMap[key] = await this.db.getColumns(rt);
1823
+ }
1824
+ }
1825
+ }
1826
+
1827
+ return buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns);
1828
+ }
1829
+
1180
1830
  /**
1181
1831
  * Regenerate payload berdasarkan perubahan yang terdeteksi.
1182
1832
  * Mempertahankan konfigurasi payload lama (action, filters, dll),
@@ -1202,6 +1852,17 @@ class SchemaValidator {
1202
1852
  : [];
1203
1853
  const primaryKey = await this.db.getPrimaryKey(tableName) || oldPayload.primaryKey;
1204
1854
 
1855
+ // Enrich kolom (constraints + boolean) sekali; dipakai untuk searchable
1856
+ // columns (datatablesWhere) dan fieldValidation. tempGenerator menyediakan
1857
+ // method enrich/generate (instance method tanpa state).
1858
+ const tempGenerator = new PayloadGenerator();
1859
+ const booleanColumns = typeof this.db.getBooleanColumns === 'function'
1860
+ ? await this.db.getBooleanColumns(tableName)
1861
+ : [];
1862
+ let enrichedColumns = tempGenerator.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
1863
+ enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
1864
+ const stringColumns = collectStringColumns(enrichedColumns);
1865
+
1205
1866
  // Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
1206
1867
  // Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
1207
1868
  // dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
@@ -1225,26 +1886,42 @@ class SchemaValidator {
1225
1886
  updatedPayload.primaryKey = primaryKey;
1226
1887
  updatedPayload.fieldName = newFieldName;
1227
1888
 
1228
- // Re-generate datatablesQuery
1889
+ // Re-generate datatablesQuery.
1890
+ // Guard `file:` reference: bila datatablesQuery existing menunjuk ke file
1891
+ // SQL external (mis. hasil --expand-fk: file:query/<table>-join.sql), JANGAN
1892
+ // timpa dengan SQL inline. Kolom JOIN dikelola di file SQL tersebut, dan
1893
+ // menimpanya akan menghapus konfigurasi JOIN sekaligus viewQuery. Hanya
1894
+ // datatablesQuery inline (atau kosong) yang diregenerasi.
1229
1895
  const fieldsStr = newFieldName.join(', ');
1230
- updatedPayload.datatablesQuery = `select ${fieldsStr} from ${tableName}`;
1896
+ const existingDq = typeof oldPayload.datatablesQuery === 'string'
1897
+ ? oldPayload.datatablesQuery.trim()
1898
+ : '';
1899
+ if (!existingDq.startsWith('file:')) {
1900
+ updatedPayload.datatablesQuery = `select ${fieldsStr} from ${tableName}`;
1901
+ }
1231
1902
 
1232
- // Re-generate exportQuery jika ada
1903
+ // Re-generate exportQuery jika ada (guard `file:` reference yang sama).
1233
1904
  if (oldPayload.exportQuery) {
1234
- updatedPayload.exportQuery = `select ${fieldsStr} from ${tableName}`;
1905
+ const existingEq = typeof oldPayload.exportQuery === 'string'
1906
+ ? oldPayload.exportQuery.trim()
1907
+ : '';
1908
+ if (!existingEq.startsWith('file:')) {
1909
+ updatedPayload.exportQuery = `select ${fieldsStr} from ${tableName}`;
1910
+ }
1235
1911
  }
1236
1912
 
1237
- // Re-generate datatablesWhere: pertahankan yang masih valid, tambahkan field baru yang searchable
1913
+ // Re-generate datatablesWhere. Entri existing dipertahankan hanya bila masih
1914
+ // ada di database DAN bertipe string; kolom non-string (mis. integer/boolean)
1915
+ // dibuang karena memicu error runtime pada upper(col) LIKE upper(?). Pola
1916
+ // nama (id/audit) TIDAK diterapkan ke entri existing agar kustomisasi string
1917
+ // user yang sengaja ditambahkan tetap aman. Kolom baru dari drift ditambahkan
1918
+ // memakai heuristik default penuh (name-exclude + string).
1238
1919
  if (oldPayload.datatablesWhere) {
1239
- const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
1240
1920
  const validWhere = oldPayload.datatablesWhere.filter(
1241
- w => w === 'all' || newFieldName.includes(w)
1921
+ w => w === 'all' || (newFieldName.includes(w) && stringColumns.has(w))
1242
1922
  );
1243
- // Tambahkan field baru yang searchable
1244
1923
  for (const col of comparison.added) {
1245
- const fieldLower = col.column.toLowerCase();
1246
- const isSearchable = !excludePatterns.some(pattern => fieldLower.includes(pattern));
1247
- if (isSearchable && !validWhere.includes(col.column)) {
1924
+ if (isDefaultSearchableColumn(col.column, stringColumns) && !validWhere.includes(col.column)) {
1248
1925
  // Sisipkan sebelum 'all'
1249
1926
  const allIdx = validWhere.indexOf('all');
1250
1927
  if (allIdx >= 0) {
@@ -1257,8 +1934,7 @@ class SchemaValidator {
1257
1934
  updatedPayload.datatablesWhere = validWhere;
1258
1935
  }
1259
1936
 
1260
- // Re-generate dateTimeFields
1261
- const tempGenerator = new PayloadGenerator();
1937
+ // Re-generate dateTimeFields (tempGenerator & enrichedColumns sudah disiapkan di atas)
1262
1938
  const dateTimeFields = tempGenerator.generateDateTimeFields(columnTypes, newFieldName);
1263
1939
  if (Object.keys(dateTimeFields).length > 0) {
1264
1940
  updatedPayload.dateTimeFields = dateTimeFields;
@@ -1267,11 +1943,6 @@ class SchemaValidator {
1267
1943
  }
1268
1944
 
1269
1945
  // Re-generate fieldValidation
1270
- const booleanColumns = typeof this.db.getBooleanColumns === 'function'
1271
- ? await this.db.getBooleanColumns(tableName)
1272
- : [];
1273
- let enrichedColumns = tempGenerator.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
1274
- enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
1275
1946
  const fieldValidation = tempGenerator.generateFieldValidation(enrichedColumns, newFieldName, primaryKey);
1276
1947
  if (fieldValidation.length > 0) {
1277
1948
  updatedPayload.fieldValidation = fieldValidation;
@@ -1297,6 +1968,12 @@ class SchemaValidator {
1297
1968
  fileName: options.fileName
1298
1969
  });
1299
1970
 
1971
+ // Built-in defaultScope berbasis is_active: tambahkan filter saat kolom
1972
+ // is_active ada di fieldName, atau lepas saat kolom sudah tidak ada lagi di
1973
+ // tabel (mis. kolom dihapus). Hanya menyentuh key is_active sehingga scope
1974
+ // kustom lain tetap dipertahankan (lihat catalogs/rdf/default-scope.md).
1975
+ applyIsActiveDefaultScope(updatedPayload, newFieldName.includes(IS_ACTIVE_COLUMN));
1976
+
1300
1977
  return updatedPayload;
1301
1978
  }
1302
1979
 
@@ -1371,5 +2048,13 @@ class SchemaValidator {
1371
2048
 
1372
2049
  module.exports = {
1373
2050
  PayloadGenerator,
1374
- SchemaValidator
2051
+ SchemaValidator,
2052
+ parseFkColumns,
2053
+ deriveTableAlias,
2054
+ pickDisplayColumn,
2055
+ buildForeignKeyExpansion,
2056
+ collectStringColumns,
2057
+ isDefaultSearchableColumn,
2058
+ applyIsActiveDefaultScope,
2059
+ buildPayloadAdvisories
1375
2060
  };