@restforgejs/platform 5.2.16 → 5.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) 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/endpoint/create.js +69 -6
  5. package/generators/cli/payload/sync.js +16 -6
  6. package/generators/cli/project/auth.js +2 -2
  7. package/generators/cli/project/sdk.js +112 -0
  8. package/generators/lib/arg-parser.js +6 -0
  9. package/generators/lib/auth/processor-generator.js +5 -3
  10. package/generators/lib/auth/templates/processor/google.js.tmpl +178 -0
  11. package/generators/lib/auth/templates/processor/login.js.tmpl +8 -8
  12. package/generators/lib/auth/templates/processor/logout.js.tmpl +2 -2
  13. package/generators/lib/auth/templates/processor/me.js.tmpl +2 -2
  14. package/generators/lib/auth/templates/processor/refresh.js.tmpl +6 -6
  15. package/generators/lib/auth/templates/processor/register.js.tmpl +4 -4
  16. package/generators/lib/auth/templates/processor/reset-password.js.tmpl +7 -7
  17. package/generators/lib/auth/templates/rfx_auth.js.tmpl +3 -0
  18. package/generators/lib/generators/model-generator.js +46 -59
  19. package/generators/lib/help-generator.js +41 -3
  20. package/generators/lib/payload/endpoint-schema-validator.js +8 -3
  21. package/generators/lib/payload/field-projections.js +116 -0
  22. package/generators/lib/payload/payload-runner.js +164 -48
  23. package/generators/lib/payload/schema-diff.js +108 -0
  24. package/generators/lib/sdk/generator.js +719 -0
  25. package/generators/lib/sdk/naming.js +48 -0
  26. package/generators/lib/sdk/runtime/README.md.tmpl +207 -0
  27. package/generators/lib/sdk/runtime/auth-client.js +186 -0
  28. package/generators/lib/sdk/runtime/deploy.mjs.tmpl +85 -0
  29. package/generators/lib/sdk/runtime/http-client.js +81 -0
  30. package/generators/lib/sdk/runtime/resource-client.js +59 -0
  31. package/generators/lib/sdk/runtime/storage.js +31 -0
  32. package/generators/lib/templates/dashboard-catalog.js +1 -1
  33. package/generators/lib/templates/db-connection-env.js +1 -1
  34. package/generators/lib/templates/dbschema-catalog.js +1 -1
  35. package/generators/lib/templates/field-validation-catalog.js +1 -1
  36. package/generators/lib/templates/mysql-template.js +1 -1
  37. package/generators/lib/templates/oracle-template.js +1 -1
  38. package/generators/lib/templates/postgres-template.js +1 -1
  39. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  40. package/generators/lib/templates/sqlite-template.js +1 -1
  41. package/generators/lib/utils/cli-output.js +40 -0
  42. package/generators/lib/utils/config-resolver.js +61 -0
  43. package/generators/lib/utils/database-introspector.js +28 -5
  44. package/integrity-manifest.json +18 -18
  45. package/package.json +1 -1
  46. package/scripts/verify-integrity.js +1 -1
  47. package/server.js +1 -1
  48. package/src/components/handlers/adjust_handler.js +1 -1
  49. package/src/components/handlers/audit_handler.js +1 -1
  50. package/src/components/handlers/delete_handler.js +1 -1
  51. package/src/components/handlers/export_handler.js +1 -1
  52. package/src/components/handlers/import_handler.js +1 -1
  53. package/src/components/handlers/insert_handler.js +1 -1
  54. package/src/components/handlers/update_handler.js +1 -1
  55. package/src/components/handlers/upload_handler.js +1 -1
  56. package/src/components/handlers/workflow_handler.js +1 -1
  57. package/src/components/integrations/webhook.js +1 -1
  58. package/src/consumers/baseConsumer.js +1 -1
  59. package/src/consumers/declarativeMapper.js +1 -1
  60. package/src/consumers/handlers/apiHandler.js +1 -1
  61. package/src/consumers/handlers/consoleHandler.js +1 -1
  62. package/src/consumers/handlers/databaseHandler.js +1 -1
  63. package/src/consumers/handlers/index.js +1 -1
  64. package/src/consumers/handlers/kafkaHandler.js +1 -1
  65. package/src/consumers/index.js +1 -1
  66. package/src/consumers/messageTransformer.js +1 -1
  67. package/src/consumers/validator.js +1 -1
  68. package/src/core/db/dialect/base-dialect.js +1 -1
  69. package/src/core/db/dialect/index.js +1 -1
  70. package/src/core/db/dialect/mysql-dialect.js +1 -1
  71. package/src/core/db/dialect/oracle-dialect.js +1 -1
  72. package/src/core/db/dialect/postgres-dialect.js +1 -1
  73. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  74. package/src/core/db/flatten-helper.js +1 -1
  75. package/src/core/db/query-builder-error.js +1 -1
  76. package/src/core/db/query-builder.js +1 -1
  77. package/src/core/db/relation-helper.js +1 -1
  78. package/src/core/handlers/delete_handler.js +1 -1
  79. package/src/core/handlers/insert_handler.js +1 -1
  80. package/src/core/handlers/update_handler.js +1 -1
  81. package/src/core/models/base-model.js +1 -1
  82. package/src/core/utils/cache-manager.js +1 -1
  83. package/src/core/utils/component-engine.js +1 -1
  84. package/src/core/utils/context-builder.js +1 -1
  85. package/src/core/utils/datetime-formatter.js +1 -1
  86. package/src/core/utils/datetime-parser.js +1 -1
  87. package/src/core/utils/db.js +1 -1
  88. package/src/core/utils/logger.js +1 -1
  89. package/src/core/utils/payload-loader.js +1 -1
  90. package/src/core/utils/security-checks.js +1 -1
  91. package/src/middleware/body-options.js +1 -1
  92. package/src/middleware/cors.js +1 -1
  93. package/src/middleware/idempotency.js +1 -1
  94. package/src/middleware/rate-limiter.js +1 -1
  95. package/src/middleware/request-logger.js +1 -1
  96. package/src/middleware/security-headers.js +1 -1
  97. package/src/models/base-model-mysql.js +1 -1
  98. package/src/models/base-model-oracle.js +1 -1
  99. package/src/models/base-model-sqlite.js +1 -1
  100. package/src/models/base-model.js +1 -1
  101. package/src/pro/caching/redis-client.js +1 -1
  102. package/src/pro/caching/redis-helper.js +1 -1
  103. package/src/pro/consumers/baseConsumer.js +1 -1
  104. package/src/pro/consumers/declarativeMapper.js +1 -1
  105. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  106. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  107. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  108. package/src/pro/consumers/handlers/index.js +1 -1
  109. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  110. package/src/pro/consumers/index.js +1 -1
  111. package/src/pro/consumers/messageTransformer.js +1 -1
  112. package/src/pro/consumers/validator.js +1 -1
  113. package/src/pro/database/base-model-mysql.js +1 -1
  114. package/src/pro/database/base-model-oracle.js +1 -1
  115. package/src/pro/database/base-model-sqlite.js +1 -1
  116. package/src/pro/database/db-mysql.js +1 -1
  117. package/src/pro/database/db-oracle.js +1 -1
  118. package/src/pro/database/db-sqlite.js +1 -1
  119. package/src/pro/excel/excel-generator.js +1 -1
  120. package/src/pro/excel/excel-parser.js +1 -1
  121. package/src/pro/excel/export-service.js +1 -1
  122. package/src/pro/excel/export_handler.js +1 -1
  123. package/src/pro/excel/import-service.js +1 -1
  124. package/src/pro/excel/import-validator.js +1 -1
  125. package/src/pro/excel/import_handler.js +1 -1
  126. package/src/pro/excel/upsert-builder.js +1 -1
  127. package/src/pro/idgen/idgen-routes.js +1 -1
  128. package/src/pro/integrations/lookup-resolver.js +1 -1
  129. package/src/pro/integrations/upload-handler-v2.js +1 -1
  130. package/src/pro/integrations/upload-handler.js +1 -1
  131. package/src/pro/integrations/webhook.js +1 -1
  132. package/src/pro/locking/lock-routes.js +1 -1
  133. package/src/pro/locking/resource-lock-manager.js +1 -1
  134. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  135. package/src/pro/messaging/kafkaService.js +1 -1
  136. package/src/pro/messaging/messagehubService.js +1 -1
  137. package/src/pro/messaging/rabbitmqService.js +1 -1
  138. package/src/pro/scheduler/job-manager.js +1 -1
  139. package/src/pro/scheduler/job-routes.js +1 -1
  140. package/src/pro/scheduler/job-validator.js +1 -1
  141. package/src/pro/storage/base-storage-provider.js +1 -1
  142. package/src/pro/storage/file-metadata-helper.js +1 -1
  143. package/src/pro/storage/index.js +1 -1
  144. package/src/pro/storage/local-storage-provider.js +1 -1
  145. package/src/pro/storage/s3-storage-provider.js +1 -1
  146. package/src/pro/storage/upload-cleanup-job.js +1 -1
  147. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  148. package/src/pro/storage/upload-pending-tracker.js +1 -1
  149. package/src/pro/websocket/broadcast-helper.js +1 -1
  150. package/src/pro/websocket/index.js +1 -1
  151. package/src/pro/websocket/livesync-server.js +1 -1
  152. package/src/pro/websocket/ws-broadcaster.js +1 -1
  153. package/src/services/export-service.js +1 -1
  154. package/src/services/import-service.js +1 -1
  155. package/src/services/kafkaConsumerService.js +1 -1
  156. package/src/services/kafkaService.js +1 -1
  157. package/src/services/messagehubService.js +1 -1
  158. package/src/services/rabbitmqService.js +1 -1
  159. package/src/utils/cache-invalidation-registry.js +1 -1
  160. package/src/utils/cache-manager.js +1 -1
  161. package/src/utils/component-engine.js +1 -1
  162. package/src/utils/config-extractor.js +1 -1
  163. package/src/utils/consumerLogger.js +1 -1
  164. package/src/utils/context-builder.js +1 -1
  165. package/src/utils/dashboard-helpers.js +1 -1
  166. package/src/utils/dateHelper.js +1 -1
  167. package/src/utils/datetime-formatter.js +1 -1
  168. package/src/utils/datetime-parser.js +1 -1
  169. package/src/utils/db-bootstrap.js +1 -1
  170. package/src/utils/db-mysql.js +1 -1
  171. package/src/utils/db-oracle.js +1 -1
  172. package/src/utils/db-sqlite.js +1 -1
  173. package/src/utils/db.js +1 -1
  174. package/src/utils/demo-generator.js +1 -1
  175. package/src/utils/excel-generator.js +1 -1
  176. package/src/utils/excel-parser.js +1 -1
  177. package/src/utils/file-watcher.js +1 -1
  178. package/src/utils/id-generator.js +1 -1
  179. package/src/utils/idempotency-manager.js +1 -1
  180. package/src/utils/import-validator.js +1 -1
  181. package/src/utils/license-client.js +1 -1
  182. package/src/utils/lock-manager.js +1 -1
  183. package/src/utils/logger.js +1 -1
  184. package/src/utils/lookup-resolver.js +1 -1
  185. package/src/utils/payload-loader.js +1 -1
  186. package/src/utils/processor-response.js +1 -1
  187. package/src/utils/rabbitmq.js +1 -1
  188. package/src/utils/redis-client.js +1 -1
  189. package/src/utils/redis-helper.js +1 -1
  190. package/src/utils/request-scope.js +1 -1
  191. package/src/utils/security-checks.js +1 -1
  192. package/src/utils/service-resolver.js +1 -1
  193. package/src/utils/shutdown-coordinator.js +1 -1
  194. package/src/utils/soft-delete-dashboard-guard.js +1 -1
  195. package/src/utils/sql-table-extractor.js +1 -1
  196. package/src/utils/trusted-keys.js +1 -1
  197. package/src/utils/upload-handler.js +1 -1
  198. package/src/utils/upsert-builder.js +1 -1
  199. package/src/utils/workflow-hook-executor.js +1 -1
@@ -125,7 +125,7 @@ function parseFkColumns(fkColumnsStr) {
125
125
  if (typeof fkColumnsStr !== 'string' || fkColumnsStr.trim().length === 0) {
126
126
  throw new Error(
127
127
  '--fk-columns is required when --expand-fk is set ' +
128
- '(format: table.column,table.column)'
128
+ '(format: ref_table.column or local_col:ref_table.column)'
129
129
  );
130
130
  }
131
131
 
@@ -137,18 +137,32 @@ function parseFkColumns(fkColumnsStr) {
137
137
  const specs = [];
138
138
  const seen = new Set();
139
139
  for (const raw of entries) {
140
- const parts = raw.split('.');
140
+ let localColumn = null;
141
+ let qualified = raw;
142
+
143
+ const colonIdx = raw.indexOf(':');
144
+ if (colonIdx !== -1) {
145
+ localColumn = raw.slice(0, colonIdx).trim();
146
+ qualified = raw.slice(colonIdx + 1).trim();
147
+ if (!localColumn) {
148
+ throw new Error(
149
+ `Invalid --fk-columns entry "${raw}": local column name before ':' is empty`
150
+ );
151
+ }
152
+ }
153
+
154
+ const parts = qualified.split('.');
141
155
  if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
142
156
  throw new Error(
143
- `Invalid --fk-columns entry "${raw}": each entry must be qualified as table.column`
157
+ `Invalid --fk-columns entry "${raw}": must be ref_table.column or local_col:ref_table.column`
144
158
  );
145
159
  }
146
160
  const table = parts[0].trim();
147
161
  const column = parts[1].trim();
148
- const key = `${table.toLowerCase()}.${column.toLowerCase()}`;
149
- if (seen.has(key)) continue;
150
- seen.add(key);
151
- specs.push({ table, column, raw });
162
+ const dedupeKey = `${localColumn ? localColumn.toLowerCase() + ':' : ''}${table.toLowerCase()}.${column.toLowerCase()}`;
163
+ if (seen.has(dedupeKey)) continue;
164
+ seen.add(dedupeKey);
165
+ specs.push({ table, column, localColumn, raw });
152
166
  }
153
167
  return specs;
154
168
  }
@@ -180,10 +194,13 @@ function deriveTableAlias(tableName) {
180
194
  * @param {{references: {schema?: string, table: string}}} fk
181
195
  * @returns {string}
182
196
  */
183
- function qualifiedRefTable(fk) {
197
+ function qualifiedRefTable(fk, dbType) {
184
198
  const schema = fk && fk.references && fk.references.schema;
185
199
  const table = fk && fk.references && fk.references.table;
186
- return (schema && schema !== 'public') ? `${schema}.${table}` : table;
200
+ if (!schema) return table;
201
+ if (dbType === 'oracle') return table;
202
+ if (schema === 'public') return table;
203
+ return `${schema}.${table}`;
187
204
  }
188
205
 
189
206
  /**
@@ -257,7 +274,7 @@ function pickDisplayColumn(refColumns, primaryKey) {
257
274
  * @param {string[]} baseColumns - kolom fisik base table (non-audit)
258
275
  * @returns {{updatedPayload: Object, sqlRelPath: string, sqlContent: string}}
259
276
  */
260
- function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns) {
277
+ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns, dbType, joinType) {
261
278
  const tableName = payload.tableName;
262
279
 
263
280
  // Base fields = fieldName yang benar-benar kolom fisik base table.
@@ -268,12 +285,16 @@ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, b
268
285
  // Map refTable(lower) -> FK; deteksi FK ganda ke tabel referensi yang sama.
269
286
  const fkByRefTable = new Map();
270
287
  const dupRefTables = new Set();
288
+ const fkByLocalCol = new Map();
271
289
  for (const fk of (foreignKeys || [])) {
272
290
  const rt = fk && fk.references && fk.references.table ? String(fk.references.table) : '';
273
291
  if (!rt) continue;
274
292
  const key = rt.toLowerCase();
275
293
  if (fkByRefTable.has(key)) dupRefTables.add(key);
276
294
  else fkByRefTable.set(key, fk);
295
+ for (const localCol of (fk.columns || [])) {
296
+ fkByLocalCol.set(String(localCol).toLowerCase(), fk);
297
+ }
277
298
  }
278
299
 
279
300
  if (fkByRefTable.size === 0) {
@@ -292,25 +313,47 @@ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, b
292
313
  };
293
314
  const baseAlias = uniqueAlias(deriveTableAlias(tableName));
294
315
 
295
- const refTableAlias = new Map(); // refKeyLower -> { alias, fk, refTableName }
296
- const refSelections = []; // { alias, column, refTableName }
316
+ const refTableAlias = new Map(); // aliasKey -> { alias, fk, refTableName }
317
+ const refSelections = []; // { alias, column, refTableName, localColumn }
297
318
  for (const spec of fkSpec) {
298
319
  const key = spec.table.toLowerCase();
299
- if (!fkByRefTable.has(key)) {
300
- const available = [...fkByRefTable.keys()].join(', ') || '(none)';
301
- throw new Error(
302
- `Foreign key reference table "${spec.table}" not found for "${tableName}". ` +
303
- `Referenced tables available: ${available}`
304
- );
305
- }
306
- if (dupRefTables.has(key)) {
307
- throw new Error(
308
- `Table "${tableName}" has multiple foreign keys referencing "${spec.table}"; ` +
309
- 'cannot disambiguate by table name'
310
- );
320
+
321
+ let fk;
322
+ if (spec.localColumn) {
323
+ const localKey = spec.localColumn.toLowerCase();
324
+ if (!fkByLocalCol.has(localKey)) {
325
+ const available = [...fkByLocalCol.keys()].join(', ') || '(none)';
326
+ throw new Error(
327
+ `Local FK column "${spec.localColumn}" not found in foreign keys of "${tableName}". ` +
328
+ `Local FK columns available: ${available}`
329
+ );
330
+ }
331
+ fk = fkByLocalCol.get(localKey);
332
+ const refTableKey = fk.references.table.toLowerCase();
333
+ if (refTableKey !== key) {
334
+ throw new Error(
335
+ `Local FK column "${spec.localColumn}" references table "${fk.references.table}", ` +
336
+ `not "${spec.table}" as specified`
337
+ );
338
+ }
339
+ } else {
340
+ if (!fkByRefTable.has(key)) {
341
+ const available = [...fkByRefTable.keys()].join(', ') || '(none)';
342
+ throw new Error(
343
+ `Foreign key reference table "${spec.table}" not found for "${tableName}". ` +
344
+ `Referenced tables available: ${available}`
345
+ );
346
+ }
347
+ if (dupRefTables.has(key)) {
348
+ throw new Error(
349
+ `Table "${tableName}" has multiple foreign keys referencing "${spec.table}"; ` +
350
+ 'cannot disambiguate by table name'
351
+ );
352
+ }
353
+ fk = fkByRefTable.get(key);
311
354
  }
312
355
 
313
- const fk = fkByRefTable.get(key);
356
+ const aliasKey = spec.localColumn ? spec.localColumn.toLowerCase() : key;
314
357
  const refTableName = fk.references.table;
315
358
  const refCols = refColumnsMap[key] || refColumnsMap[refTableName] || [];
316
359
  const refColsLower = refCols.map(c => String(c).toLowerCase());
@@ -321,17 +364,17 @@ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, b
321
364
  );
322
365
  }
323
366
 
324
- if (!refTableAlias.has(key)) {
325
- refTableAlias.set(key, {
367
+ if (!refTableAlias.has(aliasKey)) {
368
+ refTableAlias.set(aliasKey, {
326
369
  alias: uniqueAlias(deriveTableAlias(refTableName)),
327
370
  fk,
328
371
  refTableName,
329
372
  // Dipakai khusus untuk klausa JOIN (FROM/LEFT JOIN); refTableName (bare)
330
373
  // tetap dipakai untuk alias derivation & penamaan kolom output.
331
- refQualified: qualifiedRefTable(fk)
374
+ refQualified: qualifiedRefTable(fk, dbType)
332
375
  });
333
376
  }
334
- refSelections.push({ alias: refTableAlias.get(key).alias, column: spec.column, refTableName });
377
+ refSelections.push({ alias: refTableAlias.get(aliasKey).alias, column: spec.column, refTableName, localColumn: spec.localColumn || null });
335
378
  }
336
379
 
337
380
  // Output name + collision auto-prefix. Hitung occurrence nama tentatif
@@ -347,7 +390,7 @@ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, b
347
390
  for (const r of refSelections) {
348
391
  let output = r.column;
349
392
  if ((nameCount.get(r.column) || 0) > 1) {
350
- output = `${r.refTableName}_${r.column}`.toLowerCase();
393
+ output = `${r.localColumn || r.refTableName}_${r.column}`.toLowerCase();
351
394
  }
352
395
  if (finalNames.has(output)) {
353
396
  throw new Error(
@@ -380,7 +423,8 @@ function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, b
380
423
  for (let i = 0; i < pairCount; i++) {
381
424
  conds.push(`${info.alias}.${refCols[i]} = ${baseAlias}.${localCols[i]}`);
382
425
  }
383
- sqlLines.push(`LEFT JOIN ${info.refQualified} ${info.alias} ON ${conds.join(' AND ')}`);
426
+ const joinKeyword = joinType === 'inner' ? 'INNER JOIN' : 'LEFT JOIN';
427
+ sqlLines.push(`${joinKeyword} ${info.refQualified} ${info.alias} ON ${conds.join(' AND ')}`);
384
428
  }
385
429
  const sqlContent = `${sqlLines.join('\n')}\n`;
386
430
 
@@ -1181,7 +1225,7 @@ function buildPayloadAdvisories(payload, info) {
1181
1225
  type: 'fk',
1182
1226
  table,
1183
1227
  message: `has ${fkCount} foreign key(s) not surfaced as JOIN`,
1184
- command: `npx restforge payload sync --table=${table} --expand-fk`
1228
+ command: `npx restforge payload sync --table=${table} --expand-fk=both`
1185
1229
  });
1186
1230
  }
1187
1231
 
@@ -1768,7 +1812,7 @@ class PayloadGenerator {
1768
1812
  let result;
1769
1813
  if (args.sync) {
1770
1814
  result = await validator.runSync(targetTable, this, {
1771
- expandFk: args.expandFk === true,
1815
+ expandFkMode: args.expandFkMode || null,
1772
1816
  fkColumns: args.fkColumns || null
1773
1817
  });
1774
1818
  } else if (args.diff) {
@@ -2439,12 +2483,12 @@ class SchemaValidator {
2439
2483
  * @param {string|null} tableName - Nama table spesifik, atau null untuk semua
2440
2484
  * @param {PayloadGenerator} generator - Instance PayloadGenerator untuk re-generate
2441
2485
  * @param {Object} [options]
2442
- * @param {boolean} [options.expandFk] - Aktifkan FK JOIN expansion
2486
+ * @param {'both'|'datatables-only'|null} [options.expandFkMode] - Mode FK JOIN expansion
2443
2487
  * @param {string} [options.fkColumns] - Daftar kolom referensi `table.column,...`
2444
2488
  * @returns {Promise<Object>} { total, synced, skipped, error }
2445
2489
  */
2446
2490
  async runSync(tableName, generator, options = {}) {
2447
- const expandFk = options.expandFk === true;
2491
+ const expandFkMode = options.expandFkMode || null;
2448
2492
  const fkColumns = options.fkColumns || null;
2449
2493
 
2450
2494
  console.log();
@@ -2455,7 +2499,7 @@ class SchemaValidator {
2455
2499
 
2456
2500
  // Mode expand-fk butuh target tabel tunggal dan --fk-columns yang valid.
2457
2501
  // Validasi upfront agar gagal cepat dengan pesan jelas.
2458
- if (expandFk) {
2502
+ if (expandFkMode) {
2459
2503
  if (!tableName) {
2460
2504
  console.log(' [ERROR] --expand-fk requires --table=<table>');
2461
2505
  console.log();
@@ -2515,7 +2559,7 @@ class SchemaValidator {
2515
2559
  // dari sync biasa, mode ini tetap memproses meski kolom fisik sudah
2516
2560
  // in-sync (status 'ok'); bila ada drift kolom fisik, rekonsiliasi dulu
2517
2561
  // lewat regeneratePayload baru ekspansi FK diterapkan di atasnya.
2518
- if (expandFk) {
2562
+ if (expandFkMode) {
2519
2563
  let workingPayload = payload;
2520
2564
  if (comparison.status === 'drift') {
2521
2565
  try {
@@ -2530,6 +2574,9 @@ class SchemaValidator {
2530
2574
  let expansion;
2531
2575
  try {
2532
2576
  expansion = await this.applyForeignKeyExpansion(workingPayload, fkColumns, { fileName });
2577
+ if (expandFkMode === 'datatables-only') {
2578
+ expansion.updatedPayload.viewQuery = workingPayload.viewQuery;
2579
+ }
2533
2580
  } catch (e) {
2534
2581
  console.log(` [ERROR] ${fileName} - expand-fk failed: ${e.message}`);
2535
2582
  errorCount++;
@@ -2638,16 +2685,27 @@ class SchemaValidator {
2638
2685
  const refColumnsMap = {};
2639
2686
  let fkSpec;
2640
2687
 
2688
+ // Deteksi tabel referensi yang dirujuk lebih dari satu FK (ambiguous).
2689
+ // Dipakai di kedua mode (auto dan hybrid) untuk penentuan localColumn otomatis.
2690
+ const refTableCount = new Map();
2691
+ for (const fk of foreignKeys) {
2692
+ const rt = fk && fk.references && fk.references.table;
2693
+ if (!rt) continue;
2694
+ refTableCount.set(String(rt).toLowerCase(), (refTableCount.get(String(rt).toLowerCase()) || 0) + 1);
2695
+ }
2696
+ const dupRefTables = new Set(
2697
+ [...refTableCount.entries()].filter(([, n]) => n > 1).map(([k]) => k)
2698
+ );
2699
+
2641
2700
  if (isAuto) {
2642
2701
  // Auto-resolve: satu kolom display per foreign key.
2702
+ // FK ganda ke tabel yang sama di-disambiguasi otomatis via nama kolom FK
2703
+ // lokal sebagai prefix — tidak error, tidak butuh --fk-columns.
2643
2704
  fkSpec = [];
2644
2705
  for (const fk of foreignKeys) {
2645
2706
  const rt = fk && fk.references && fk.references.table;
2646
2707
  if (!rt) continue;
2647
2708
  const key = String(rt).toLowerCase();
2648
- // qualifiedRefTable() dipakai untuk query ke DB (perlu schema yang benar
2649
- // bila parent bukan di schema 'public'); `rt` (bare) tetap dipakai sebagai
2650
- // key map + identitas fkSpec (selaras format `--fk-columns=table.column`).
2651
2709
  const qualifiedRt = qualifiedRefTable(fk);
2652
2710
  if (!refColumnsMap[key]) {
2653
2711
  refColumnsMap[key] = await this.db.getColumns(qualifiedRt);
@@ -2662,25 +2720,83 @@ class SchemaValidator {
2662
2720
  '(no name/code column and no primary key found). Provide --fk-columns explicitly.'
2663
2721
  );
2664
2722
  }
2665
- fkSpec.push({ table: rt, column: display, raw: `${rt}.${display}` });
2666
- console.log(` [AUTO] ${tableName} -> ${rt}.${display} (display column)`);
2723
+ const isAmbiguous = dupRefTables.has(key);
2724
+ const localColumn = isAmbiguous ? (fk.columns && fk.columns[0]) || null : null;
2725
+ fkSpec.push({ table: rt, column: display, localColumn, raw: `${rt}.${display}` });
2726
+ if (isAmbiguous && localColumn) {
2727
+ console.log(` [AUTO] ${tableName}.${localColumn} -> ${rt}.${display} (display column, auto-disambiguated)`);
2728
+ } else {
2729
+ console.log(` [AUTO] ${tableName} -> ${rt}.${display} (display column)`);
2730
+ }
2667
2731
  }
2668
2732
  } else {
2669
- // Eksplisit: parse spec lalu fetch kolom tabel referensi yang disebut.
2670
- fkSpec = parseFkColumns(fkColumnsStr);
2671
- const neededTables = new Set(fkSpec.map(s => s.table.toLowerCase()));
2733
+ // Hybrid: --fk-columns berlaku sebagai override/disambiguasi untuk FK yang
2734
+ // disebut; FK lain yang tidak disebut tetap di-auto-resolve.
2735
+ const explicitSpecs = parseFkColumns(fkColumnsStr);
2736
+
2737
+ // Index explicit specs: by localColumn (untuk FK ganda) atau by refTable (untuk FK tunggal).
2738
+ const explicitByLocalCol = new Map();
2739
+ const explicitByTable = new Map();
2740
+ for (const spec of explicitSpecs) {
2741
+ if (spec.localColumn) {
2742
+ explicitByLocalCol.set(spec.localColumn.toLowerCase(), spec);
2743
+ } else {
2744
+ const k = spec.table.toLowerCase();
2745
+ if (!explicitByTable.has(k)) explicitByTable.set(k, spec);
2746
+ }
2747
+ }
2748
+
2749
+ fkSpec = [];
2672
2750
  for (const fk of foreignKeys) {
2673
2751
  const rt = fk && fk.references && fk.references.table;
2674
2752
  if (!rt) continue;
2675
2753
  const key = String(rt).toLowerCase();
2676
- if (!neededTables.has(key)) continue;
2754
+ const localColRaw = fk.columns && fk.columns[0];
2755
+ const localColKey = localColRaw ? String(localColRaw).toLowerCase() : null;
2756
+ const isAmbiguous = dupRefTables.has(key);
2757
+
2758
+ // Cari explicit spec yang cocok dengan FK ini.
2759
+ let matchedSpec = null;
2760
+ if (localColKey && explicitByLocalCol.has(localColKey)) {
2761
+ matchedSpec = explicitByLocalCol.get(localColKey);
2762
+ } else if (!isAmbiguous && explicitByTable.has(key)) {
2763
+ matchedSpec = explicitByTable.get(key);
2764
+ }
2765
+
2766
+ // Fetch kolom tabel referensi (dibutuhkan baik untuk explicit maupun auto).
2767
+ const qualifiedRt = qualifiedRefTable(fk);
2677
2768
  if (!refColumnsMap[key]) {
2678
- refColumnsMap[key] = await this.db.getColumns(qualifiedRefTable(fk));
2769
+ refColumnsMap[key] = await this.db.getColumns(qualifiedRt);
2770
+ }
2771
+
2772
+ if (matchedSpec) {
2773
+ fkSpec.push(matchedSpec);
2774
+ const prefix = matchedSpec.localColumn ? `${tableName}.${matchedSpec.localColumn}` : tableName;
2775
+ console.log(` [EXPLICIT] ${prefix} -> ${matchedSpec.table}.${matchedSpec.column}`);
2776
+ } else {
2777
+ // FK tidak disebut di --fk-columns: auto-resolve.
2778
+ const refPk = typeof this.db.getPrimaryKey === 'function'
2779
+ ? await this.db.getPrimaryKey(qualifiedRt)
2780
+ : null;
2781
+ const display = pickDisplayColumn(refColumnsMap[key], refPk);
2782
+ if (!display) {
2783
+ throw new Error(
2784
+ `Could not auto-resolve a display column for referenced table "${rt}" ` +
2785
+ '(no name/code column and no primary key found). Provide --fk-columns explicitly.'
2786
+ );
2787
+ }
2788
+ const localColumn = isAmbiguous ? localColRaw || null : null;
2789
+ fkSpec.push({ table: rt, column: display, localColumn, raw: `${rt}.${display}` });
2790
+ if (isAmbiguous && localColumn) {
2791
+ console.log(` [AUTO] ${tableName}.${localColumn} -> ${rt}.${display} (display column, auto-disambiguated)`);
2792
+ } else {
2793
+ console.log(` [AUTO] ${tableName} -> ${rt}.${display} (display column)`);
2794
+ }
2679
2795
  }
2680
2796
  }
2681
2797
  }
2682
2798
 
2683
- return buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns);
2799
+ return buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns, this.db.dbType, this.db.fkAutoJoin || 'left');
2684
2800
  }
2685
2801
 
2686
2802
  /**
@@ -190,9 +190,16 @@ async function compareSchemaStrict(payload, db, options = {}) {
190
190
 
191
191
  const { explicit, audit } = resolveEffectiveFieldList(payload);
192
192
 
193
+ // Bila ada kegagalan resolusi query (file: SQL error), kolom non-fisik yang
194
+ // tidak ditemukan di validColumnSet kemungkinan kolom JOIN yang tidak bisa
195
+ // diverifikasi — bukan drift nyata. Jangan laporkan sebagai removed agar
196
+ // SQL error tetap menjadi satu-satunya blocker yang terlihat.
197
+ const hasQueryFailures = (querySourceResult.warnings || []).length > 0;
198
+
193
199
  const explicitSet = new Set(explicit);
194
200
  for (const field of explicit) {
195
201
  if (!validColumnSet.has(field)) {
202
+ if (hasQueryFailures && !dbColumnSet.has(field)) continue;
196
203
  result.removed.push({ column: field, source: 'payload' });
197
204
  }
198
205
  }
@@ -486,6 +493,106 @@ function substituteQueryPlaceholders(sql, tableName) {
486
493
  return sql.replace(/\$\{tableName\}/g, tableName);
487
494
  }
488
495
 
496
+ /**
497
+ * Resolve kolom per-sumber untuk derivasi field projections di Phase 02.
498
+ *
499
+ * Mengembalikan:
500
+ * - `physicalColumns`: nama kolom fisik tabel dari getDetailedColumnInfo
501
+ * - `readSourceColumns`: kolom dari sumber-baca yang menang resolusi
502
+ * (viewName ≠ tableName → viewName; ada viewQuery → viewQuery; else → physicalColumns)
503
+ * [] berarti sumber ada tapi introspeksi gagal (hard-fail trigger di deriveFieldProjections)
504
+ * - `datatablesColumns`: kolom dari datatablesQuery.
505
+ * null = tidak ada datatablesQuery; [] = ada tapi introspeksi gagal
506
+ *
507
+ * Dipanggil di alur `endpoint create` menggunakan db handle yang sama dengan
508
+ * compareSchemaStrict (reuse koneksi, tidak buka koneksi baru — D2).
509
+ *
510
+ * @param {Object} payload
511
+ * @param {Object} db - DatabaseIntrospector terkoneksi
512
+ * @param {Object} [options]
513
+ * @param {string} [options.payloadDir] - Folder payload untuk resolve file: reference
514
+ * @returns {Promise<{physicalColumns: string[], readSourceColumns: string[], datatablesColumns: string[]|null}>}
515
+ */
516
+ async function resolveFieldProjectionInputs(payload, db, options = {}) {
517
+ const payloadDir = options.payloadDir || null;
518
+ const tableName = payload.tableName;
519
+
520
+ // 1. physicalColumns dari getDetailedColumnInfo
521
+ let physicalColumns = [];
522
+ try {
523
+ const dbCols = await db.getDetailedColumnInfo(tableName);
524
+ physicalColumns = Array.isArray(dbCols) ? dbCols.map(c => c.column_name) : [];
525
+ } catch (_err) {
526
+ // Gagal → physicalColumns tetap []
527
+ }
528
+
529
+ // 2. readSourceColumns: priority viewName → viewQuery → tableName (physicalColumns)
530
+ const hasViewName = typeof payload.viewName === 'string' &&
531
+ payload.viewName.trim().length > 0 &&
532
+ payload.viewName.trim() !== tableName;
533
+ const hasViewQuery = typeof payload.viewQuery === 'string' &&
534
+ payload.viewQuery.trim().length > 0;
535
+
536
+ let readSourceColumns;
537
+ if (hasViewName) {
538
+ // P1: viewName berbeda dari tableName — introspeksi view
539
+ try {
540
+ const viewCols = await db.getDetailedColumnInfo(payload.viewName.trim());
541
+ readSourceColumns = Array.isArray(viewCols) ? viewCols.map(c => c.column_name) : [];
542
+ } catch (_err) {
543
+ readSourceColumns = [];
544
+ }
545
+ } else if (hasViewQuery) {
546
+ // P2: viewQuery — describe query
547
+ const resolved = resolveSqlSource(payload.viewQuery, payloadDir, 'viewQuery');
548
+ if (!resolved.ok) {
549
+ readSourceColumns = [];
550
+ } else {
551
+ const sql = substituteQueryPlaceholders(resolved.sql, tableName);
552
+ if (typeof db.describeQueryColumns !== 'function') {
553
+ readSourceColumns = [];
554
+ } else {
555
+ try {
556
+ const desc = await db.describeQueryColumns(sql);
557
+ readSourceColumns = (desc.ok && Array.isArray(desc.columns))
558
+ ? desc.columns
559
+ : [];
560
+ } catch (_err) {
561
+ readSourceColumns = [];
562
+ }
563
+ }
564
+ }
565
+ } else {
566
+ // Fallback: read source = tableName → gunakan physicalColumns
567
+ readSourceColumns = physicalColumns;
568
+ }
569
+
570
+ // 3. datatablesColumns dari datatablesQuery (null bila tidak ada)
571
+ let datatablesColumns = null;
572
+ if (typeof payload.datatablesQuery === 'string' && payload.datatablesQuery.trim().length > 0) {
573
+ const resolved = resolveSqlSource(payload.datatablesQuery, payloadDir, 'datatablesQuery');
574
+ if (!resolved.ok) {
575
+ datatablesColumns = [];
576
+ } else {
577
+ const sql = substituteQueryPlaceholders(resolved.sql, tableName);
578
+ if (typeof db.describeQueryColumns !== 'function') {
579
+ datatablesColumns = [];
580
+ } else {
581
+ try {
582
+ const desc = await db.describeQueryColumns(sql);
583
+ datatablesColumns = (desc.ok && Array.isArray(desc.columns))
584
+ ? desc.columns
585
+ : [];
586
+ } catch (_err) {
587
+ datatablesColumns = [];
588
+ }
589
+ }
590
+ }
591
+ }
592
+
593
+ return { physicalColumns, readSourceColumns, datatablesColumns };
594
+ }
595
+
489
596
  module.exports = {
490
597
  DEFAULT_EXCLUDED_COLUMNS,
491
598
  normalizeDatabaseType,
@@ -493,5 +600,6 @@ module.exports = {
493
600
  compareSchemaStrict,
494
601
  formatDriftReport,
495
602
  resolveQuerySourceColumns,
603
+ resolveFieldProjectionInputs,
496
604
  resolveSqlSource
497
605
  };