@restforgejs/platform 5.1.7 → 5.1.20

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 (208) hide show
  1. package/bin/restforge-hwinfo-linux +0 -0
  2. package/bin/restforge-hwinfo.exe +0 -0
  3. package/build-info.json +2 -2
  4. package/cli/consumer-deploy.js +1 -1
  5. package/cli/consumer.js +1 -1
  6. package/generators/cli/catalog/dbschema.js +2 -1
  7. package/generators/cli/endpoint/list.js +264 -0
  8. package/generators/cli/fast-track.js +395 -37
  9. package/generators/cli/payload/generate.js +10 -2
  10. package/generators/cli/processor/create.js +7 -7
  11. package/generators/cli/processor/list.js +229 -0
  12. package/generators/cli/schema/apply.js +6 -1
  13. package/generators/cli/schema/diff.js +6 -1
  14. package/generators/cli/schema/introspect.js +32 -11
  15. package/generators/lib/data/db-executor.js +8 -8
  16. package/generators/lib/data/envelope.js +3 -3
  17. package/generators/lib/dbschema-kit/apply-engine.js +20 -0
  18. package/generators/lib/dbschema-kit/dialect/mysql.js +2 -0
  19. package/generators/lib/dbschema-kit/dialect/oracle.js +2 -0
  20. package/generators/lib/dbschema-kit/dialect/postgres.js +4 -0
  21. package/generators/lib/dbschema-kit/dialect/sqlite.js +5 -0
  22. package/generators/lib/dbschema-kit/diff-engine.js +22 -1
  23. package/generators/lib/dbschema-kit/diff-reporter.js +293 -272
  24. package/generators/lib/dbschema-kit/emitters/create-index.js +23 -1
  25. package/generators/lib/dbschema-kit/emitters/create-table.js +48 -0
  26. package/generators/lib/dbschema-kit/introspect-mapper.js +154 -2
  27. package/generators/lib/dbschema-kit/ir-builder.js +84 -1
  28. package/generators/lib/dbschema-kit/schema-printer.js +20 -0
  29. package/generators/lib/dbschema-kit/soft-delete-constants.js +111 -0
  30. package/generators/lib/dbschema-kit/validator/schema-validator.js +231 -0
  31. package/generators/lib/generators/dashboard-generator.js +5 -5
  32. package/generators/lib/generators/processor-validation-generator.js +16 -16
  33. package/generators/lib/payload/payload-runner.js +774 -1
  34. package/generators/lib/payload/schema-diff.js +7 -0
  35. package/generators/lib/templates/dashboard-catalog.js +1 -1
  36. package/generators/lib/templates/db-connection-env.js +1 -1
  37. package/generators/lib/templates/dbschema-catalog.js +1 -1
  38. package/generators/lib/templates/field-validation-catalog.js +1 -1
  39. package/generators/lib/templates/mysql-template.js +1 -1
  40. package/generators/lib/templates/oracle-template.js +1 -1
  41. package/generators/lib/templates/postgres-template.js +1 -1
  42. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  43. package/generators/lib/templates/sqlite-template.js +1 -1
  44. package/generators/lib/utils/database-introspector.js +48 -0
  45. package/generators/lib/utils/env-manager.js +4 -4
  46. package/generators/lib/utils/file-utils.js +6 -6
  47. package/generators/lib/utils/payload-processor.js +18 -2
  48. package/generators/lib/validators/argument-validator.js +2 -2
  49. package/generators/lib/validators/dashboard-validator.js +35 -1
  50. package/generators/lib/validators/payload-validator.js +460 -33
  51. package/integrity-manifest.json +20 -20
  52. package/package.json +2 -1
  53. package/scripts/check-install.js +8 -8
  54. package/scripts/verify-integrity.js +1 -1
  55. package/server.js +1 -1
  56. package/src/components/handlers/adjust_handler.js +1 -1
  57. package/src/components/handlers/audit_handler.js +1 -1
  58. package/src/components/handlers/delete_handler.js +1 -1
  59. package/src/components/handlers/export_handler.js +1 -1
  60. package/src/components/handlers/import_handler.js +1 -1
  61. package/src/components/handlers/insert_handler.js +1 -1
  62. package/src/components/handlers/update_handler.js +1 -1
  63. package/src/components/handlers/upload_handler.js +1 -1
  64. package/src/components/handlers/workflow_handler.js +1 -1
  65. package/src/components/integrations/webhook.js +1 -1
  66. package/src/consumers/baseConsumer.js +1 -1
  67. package/src/consumers/declarativeMapper.js +1 -1
  68. package/src/consumers/handlers/apiHandler.js +1 -1
  69. package/src/consumers/handlers/consoleHandler.js +1 -1
  70. package/src/consumers/handlers/databaseHandler.js +1 -1
  71. package/src/consumers/handlers/index.js +1 -1
  72. package/src/consumers/handlers/kafkaHandler.js +1 -1
  73. package/src/consumers/index.js +1 -1
  74. package/src/consumers/messageTransformer.js +1 -1
  75. package/src/consumers/validator.js +1 -1
  76. package/src/core/db/dialect/base-dialect.js +1 -1
  77. package/src/core/db/dialect/index.js +1 -1
  78. package/src/core/db/dialect/mysql-dialect.js +1 -1
  79. package/src/core/db/dialect/oracle-dialect.js +1 -1
  80. package/src/core/db/dialect/postgres-dialect.js +1 -1
  81. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  82. package/src/core/db/flatten-helper.js +1 -1
  83. package/src/core/db/query-builder-error.js +1 -1
  84. package/src/core/db/query-builder.js +1 -1
  85. package/src/core/db/relation-helper.js +1 -1
  86. package/src/core/handlers/delete_handler.js +1 -1
  87. package/src/core/handlers/insert_handler.js +1 -1
  88. package/src/core/handlers/update_handler.js +1 -1
  89. package/src/core/models/base-model.js +1 -1
  90. package/src/core/utils/cache-manager.js +1 -1
  91. package/src/core/utils/component-engine.js +1 -1
  92. package/src/core/utils/context-builder.js +1 -1
  93. package/src/core/utils/datetime-formatter.js +1 -1
  94. package/src/core/utils/datetime-parser.js +1 -1
  95. package/src/core/utils/db.js +1 -1
  96. package/src/core/utils/logger.js +1 -1
  97. package/src/core/utils/payload-loader.js +1 -1
  98. package/src/core/utils/security-checks.js +1 -1
  99. package/src/middleware/body-options.js +1 -1
  100. package/src/middleware/cors.js +1 -1
  101. package/src/middleware/idempotency.js +1 -1
  102. package/src/middleware/rate-limiter.js +1 -1
  103. package/src/middleware/request-logger.js +1 -1
  104. package/src/middleware/security-headers.js +1 -1
  105. package/src/models/base-model-mysql.js +1 -1
  106. package/src/models/base-model-oracle.js +1 -1
  107. package/src/models/base-model-sqlite.js +1 -1
  108. package/src/models/base-model.js +1 -1
  109. package/src/pro/caching/redis-client.js +1 -1
  110. package/src/pro/caching/redis-helper.js +1 -1
  111. package/src/pro/consumers/baseConsumer.js +1 -1
  112. package/src/pro/consumers/declarativeMapper.js +1 -1
  113. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  114. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  115. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  116. package/src/pro/consumers/handlers/index.js +1 -1
  117. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  118. package/src/pro/consumers/index.js +1 -1
  119. package/src/pro/consumers/messageTransformer.js +1 -1
  120. package/src/pro/consumers/validator.js +1 -1
  121. package/src/pro/database/base-model-mysql.js +1 -1
  122. package/src/pro/database/base-model-oracle.js +1 -1
  123. package/src/pro/database/base-model-sqlite.js +1 -1
  124. package/src/pro/database/db-mysql.js +1 -1
  125. package/src/pro/database/db-oracle.js +1 -1
  126. package/src/pro/database/db-sqlite.js +1 -1
  127. package/src/pro/excel/excel-generator.js +1 -1
  128. package/src/pro/excel/excel-parser.js +1 -1
  129. package/src/pro/excel/export-service.js +1 -1
  130. package/src/pro/excel/export_handler.js +1 -1
  131. package/src/pro/excel/import-service.js +1 -1
  132. package/src/pro/excel/import-validator.js +1 -1
  133. package/src/pro/excel/import_handler.js +1 -1
  134. package/src/pro/excel/upsert-builder.js +1 -1
  135. package/src/pro/idgen/idgen-routes.js +1 -1
  136. package/src/pro/integrations/lookup-resolver.js +1 -1
  137. package/src/pro/integrations/upload-handler-v2.js +1 -1
  138. package/src/pro/integrations/upload-handler.js +1 -1
  139. package/src/pro/integrations/webhook.js +1 -1
  140. package/src/pro/locking/lock-routes.js +1 -1
  141. package/src/pro/locking/resource-lock-manager.js +1 -1
  142. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  143. package/src/pro/messaging/kafkaService.js +1 -1
  144. package/src/pro/messaging/messagehubService.js +1 -1
  145. package/src/pro/messaging/rabbitmqService.js +1 -1
  146. package/src/pro/scheduler/job-manager.js +1 -1
  147. package/src/pro/scheduler/job-routes.js +1 -1
  148. package/src/pro/scheduler/job-validator.js +1 -1
  149. package/src/pro/storage/base-storage-provider.js +1 -1
  150. package/src/pro/storage/file-metadata-helper.js +1 -1
  151. package/src/pro/storage/index.js +1 -1
  152. package/src/pro/storage/local-storage-provider.js +1 -1
  153. package/src/pro/storage/s3-storage-provider.js +1 -1
  154. package/src/pro/storage/upload-cleanup-job.js +1 -1
  155. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  156. package/src/pro/storage/upload-pending-tracker.js +1 -1
  157. package/src/pro/websocket/broadcast-helper.js +1 -1
  158. package/src/pro/websocket/index.js +1 -1
  159. package/src/pro/websocket/livesync-server.js +1 -1
  160. package/src/pro/websocket/ws-broadcaster.js +1 -1
  161. package/src/services/export-service.js +1 -1
  162. package/src/services/import-service.js +1 -1
  163. package/src/services/kafkaConsumerService.js +1 -1
  164. package/src/services/kafkaService.js +1 -1
  165. package/src/services/messagehubService.js +1 -1
  166. package/src/services/rabbitmqService.js +1 -1
  167. package/src/utils/cache-invalidation-registry.js +1 -1
  168. package/src/utils/cache-manager.js +1 -1
  169. package/src/utils/component-engine.js +1 -1
  170. package/src/utils/config-extractor.js +1 -1
  171. package/src/utils/consumerLogger.js +1 -1
  172. package/src/utils/context-builder.js +1 -1
  173. package/src/utils/dashboard-helpers.js +1 -1
  174. package/src/utils/dateHelper.js +1 -1
  175. package/src/utils/datetime-formatter.js +1 -1
  176. package/src/utils/datetime-parser.js +1 -1
  177. package/src/utils/db-bootstrap.js +1 -1
  178. package/src/utils/db-mysql.js +1 -1
  179. package/src/utils/db-oracle.js +1 -1
  180. package/src/utils/db-sqlite.js +1 -1
  181. package/src/utils/db.js +1 -1
  182. package/src/utils/demo-generator.js +1 -1
  183. package/src/utils/excel-generator.js +1 -1
  184. package/src/utils/excel-parser.js +1 -1
  185. package/src/utils/file-watcher.js +1 -1
  186. package/src/utils/id-generator.js +1 -1
  187. package/src/utils/idempotency-manager.js +1 -1
  188. package/src/utils/import-validator.js +1 -1
  189. package/src/utils/license-client.js +1 -1
  190. package/src/utils/lock-manager.js +1 -1
  191. package/src/utils/logger.js +1 -1
  192. package/src/utils/lookup-resolver.js +1 -1
  193. package/src/utils/payload-loader.js +1 -1
  194. package/src/utils/processor-response.js +1 -1
  195. package/src/utils/rabbitmq.js +1 -1
  196. package/src/utils/redis-client.js +1 -1
  197. package/src/utils/redis-helper.js +1 -1
  198. package/src/utils/request-scope.js +1 -1
  199. package/src/utils/security-checks.js +1 -1
  200. package/src/utils/service-resolver.js +1 -1
  201. package/src/utils/shutdown-coordinator.js +1 -1
  202. package/src/utils/soft-delete-dashboard-guard.js +1 -0
  203. package/src/utils/sql-table-extractor.js +1 -0
  204. package/src/utils/trusted-keys.js +1 -1
  205. package/src/utils/upload-handler.js +1 -1
  206. package/src/utils/upsert-builder.js +1 -1
  207. package/src/utils/workflow-hook-executor.js +1 -1
  208. package/generators/lib/utils/sql-table-extractor.js +0 -83
@@ -32,6 +32,18 @@ const {
32
32
  detectAuditAlignment
33
33
  } = require('../utils/audit-columns');
34
34
  const { compareSchemaStrict } = require('./schema-diff');
35
+ const { loadSchemaPath } = require('../dbschema-kit/loader');
36
+ const {
37
+ SOFT_DELETE_COLUMNS,
38
+ isSoftDeleteEnabled,
39
+ softDeleteCheckName
40
+ } = require('../dbschema-kit/soft-delete-constants');
41
+ const softDeleteDashboardGuard = require('../../../src/utils/soft-delete-dashboard-guard');
42
+
43
+ // Panjang identifier maksimum PostgreSQL (Fase 1 PostgreSQL-only). Dipakai untuk
44
+ // menderivasi nama CHECK soft-delete saat menentukan status parent-soft-delete pada
45
+ // FK-awareness (R20), identik dengan emitter forward (Phase 02) + reverse (Phase 03).
46
+ const POSTGRES_MAX_IDENTIFIER_LENGTH = 63;
35
47
 
36
48
  // Kolom audit yang di-handle otomatis oleh RESTForge runtime (base-model).
37
49
  // Konstanta dipakai dari shared util agar source-of-truth tunggal lintas
@@ -487,6 +499,634 @@ function applyIsActiveDefaultScope(payload, hasIsActive) {
487
499
  if (Object.keys(ds).length === 0) delete payload.defaultScope;
488
500
  }
489
501
 
502
+ // ============================================================================
503
+ // SOFT-DELETE DERIVATION (SDF -> RDF) — payload generate (R12/R13)
504
+ // ============================================================================
505
+
506
+ /**
507
+ * Cari IR model untuk sebuah tabel di dalam Map hasil loadSchemaPath().
508
+ *
509
+ * Strategi match deterministik:
510
+ * 1. Exact qualifiedName: `models.get(tableName)` (Map dikunci qualifiedName).
511
+ * 2. Bare name: cocokkan `model.tableName === <bare>` (schema prefix di-strip)
512
+ * atau `model.qualifiedName === tableName`.
513
+ *
514
+ * Bila lebih dari satu model cocok dengan bare name (mis. tabel sama di dua schema),
515
+ * lempar ERROR ambiguous — pemanggil harus qualify dengan schema.table (R1, strict).
516
+ *
517
+ * @param {Map<string, Object>} models - hasil loadSchemaPath()
518
+ * @param {string} tableName - nama tabel (bare atau schema.table)
519
+ * @returns {Object|null} IR model atau null bila tidak ditemukan
520
+ */
521
+ function findModelByTable(models, tableName) {
522
+ if (models.has(tableName)) return models.get(tableName);
523
+
524
+ const bare = String(tableName).split('.').pop();
525
+ const matches = [];
526
+ for (const model of models.values()) {
527
+ if (model.tableName === bare || model.qualifiedName === tableName) {
528
+ matches.push(model);
529
+ }
530
+ }
531
+
532
+ if (matches.length === 1) return matches[0];
533
+ if (matches.length > 1) {
534
+ throw new Error(
535
+ `Table '${tableName}' is ambiguous in the SDF: ${matches.length} models match the bare ` +
536
+ `name '${bare}'. Qualify --table with the schema (schema.table) to disambiguate.`
537
+ );
538
+ }
539
+ return null;
540
+ }
541
+
542
+ /**
543
+ * Resolusi blok `softDelete` SDF untuk tabel yang terdeteksi punya kolom soft-delete
544
+ * di database (R12). Memuat SDF via `loadSchemaPath` (jalur identik dengan command
545
+ * `schema`), mencari IR tabel, lalu memastikan blok `softDelete` valid (enabled).
546
+ *
547
+ * Aturan ERROR (tabel R12) — soft-delete WAJIB punya SDF yang konsisten:
548
+ * - `--schema-path` kosong -> ERROR (tidak bisa menurunkan blok)
549
+ * - SDF gagal di-muat / SDF tidak valid -> ERROR (loadSchemaPath menjalankan
550
+ * validateSchema; SDF dengan softDelete invalid gagal di sini)
551
+ * - tabel tidak ada di SDF -> ERROR (SDF tidak ditemukan untuk tabel)
552
+ * - tabel ada tapi tanpa softDelete valid-> ERROR (softDelete.enabled !== true)
553
+ *
554
+ * `schemaPath` adalah nilai flag `--schema-path` (default `"schema"`, selaras
555
+ * `data pull`/`data push`); di-resolve relatif cwd oleh `loadSchemaPath`. Tabel
556
+ * soft-delete tanpa `--schema-path` eksplisit memakai default `"schema"`; bila
557
+ * folder itu tidak ada, ERROR "failed to load SDF" mengarahkan ke `--schema-path`.
558
+ *
559
+ * Validasi semantik blok (R3/R5/R7/R9) sudah ditegakkan `validateSoftDelete` saat
560
+ * load, jadi blok yang dikembalikan di sini dijamin enabled + kolom lengkap + reusable
561
+ * memenuhi syarat panjang.
562
+ *
563
+ * @param {string} tableName - nama tabel target (dari --table)
564
+ * @param {string|null} schemaPath - nilai flag --schema-path
565
+ * @returns {{ enabled: true, reusable?: Array<{field: string, length: number}> }} blok softDelete IR
566
+ * @throws {Error} bila salah satu kondisi R12 ERROR terpenuhi
567
+ */
568
+ function resolveSoftDeleteForTable(tableName, schemaPath) {
569
+ if (typeof schemaPath !== 'string' || schemaPath.trim() === '') {
570
+ throw new Error(
571
+ `Table '${tableName}' has soft-delete columns (${SOFT_DELETE_COLUMNS.join('/')}) but ` +
572
+ `--schema-path was not provided. payload generate must read the SDF to derive the softDelete ` +
573
+ `block of the RDF (base length is not stored in the database, R12/R13). ` +
574
+ `Re-run with --schema-path=<path-to-sdf-file-or-folder>.`
575
+ );
576
+ }
577
+
578
+ let models;
579
+ try {
580
+ models = loadSchemaPath(schemaPath);
581
+ } catch (err) {
582
+ throw new Error(
583
+ `Table '${tableName}': failed to load SDF from --schema-path='${schemaPath}': ${err.message}`
584
+ );
585
+ }
586
+
587
+ const ir = findModelByTable(models, tableName);
588
+ if (!ir) {
589
+ throw new Error(
590
+ `Table '${tableName}' has soft-delete columns but is not declared in the SDF at ` +
591
+ `'${schemaPath}'. Add the table (with a valid softDelete block) to the SDF, or remove ` +
592
+ `the soft-delete columns from the database (R12).`
593
+ );
594
+ }
595
+
596
+ if (!isSoftDeleteEnabled(ir)) {
597
+ throw new Error(
598
+ `Table '${tableName}' has soft-delete columns but its SDF declaration has no valid ` +
599
+ `softDelete block (softDelete.enabled !== true). Declare softDelete in the SDF to generate ` +
600
+ `a soft-delete RDF (R12).`
601
+ );
602
+ }
603
+
604
+ return ir.softDelete;
605
+ }
606
+
607
+ /**
608
+ * Turunkan blok `softDelete` RDF dari blok `softDelete` SDF, secara in-place pada
609
+ * `payload` (pola `applyIsActiveDefaultScope`). Dipanggil HANYA bila tabel terdeteksi
610
+ * soft-delete dan SDF sudah diresolusi valid (R12), jadi seluruh mutasi guarded oleh
611
+ * deteksi (backward-compat byte-identik baseline untuk tabel non-soft-delete, R13).
612
+ *
613
+ * Dua mutasi:
614
+ * 1. Set `payload.softDelete = { enabled, reusable, visibility }`. `enabled`+`reusable`
615
+ * dari SDF; `visibility` default "active_only" (config endpoint, BUKAN turunan SDF —
616
+ * developer ubah per-endpoint nanti, R13/R15). Urutan key tetap enabled→reusable→
617
+ * visibility sesuai R13.
618
+ * 2. Override `fieldValidation[field].maxLength = base length` untuk tiap field reusable
619
+ * (R13): batasi input user ke base sehingga `base + 38 = physical` pas tanpa overflow.
620
+ * Default `generateFieldValidation` memakai character_maximum_length (physical 88);
621
+ * di sinilah perilaku default di-override pasca-generate.
622
+ *
623
+ * Idempoten dan tidak menyentuh field lain di payload.
624
+ *
625
+ * @param {Object} payload - payloadData yang dimutasi
626
+ * @param {{ enabled: boolean, reusable?: Array<{field: string, length: number}> }} sdfSoftDelete
627
+ * @returns {void}
628
+ */
629
+ function applySoftDeleteDerivation(payload, sdfSoftDelete) {
630
+ const reusable = Array.isArray(sdfSoftDelete && sdfSoftDelete.reusable)
631
+ ? sdfSoftDelete.reusable.map((entry) => ({ field: entry.field, length: entry.length }))
632
+ : [];
633
+
634
+ payload.softDelete = {
635
+ enabled: true,
636
+ reusable,
637
+ visibility: 'active_only'
638
+ };
639
+
640
+ // Override maxLength reusable -> base length (R13). Entry pasti ada: reusable field
641
+ // wajib unique string (R9) sehingga generateFieldValidation menghasilkan entry-nya.
642
+ if (Array.isArray(payload.fieldValidation)) {
643
+ for (const entry of reusable) {
644
+ const fv = payload.fieldValidation.find((f) => f && f.name === entry.field);
645
+ if (fv && fv.constraints && typeof fv.constraints === 'object') {
646
+ fv.constraints.maxLength = entry.length;
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Checkpoint soft-delete dashboard (WARNING, non-blocking) untuk `payload generate`.
654
+ *
655
+ * Saat RDF tabel soft-delete baru ditulis, scan dashboard yang sudah ada di `payloadDir`
656
+ * terhadap registry soft-delete saat ini. `payload generate` TIDAK meregenerasi dashboard
657
+ * yang sudah ada, sehingga bila soft-delete baru diaktifkan pada sebuah tabel, module
658
+ * dashboard lama tetap memuat SQL bocor (ikut membaca baris is_deleted=true). Tiap
659
+ * pelanggaran di-emit sebagai peringatan menonjol yang menyebut dashboard + widget + tabel
660
+ * pemicu, plus penjelasan bahwa dashboard harus diperbaiki lalu diregenerasi manual.
661
+ *
662
+ * WAJIB non-blocking: tidak melempar error. Generate RDF tetap selesai sukses meski ada
663
+ * peringatan. Hard stop atas drift ini ada di checkpoint runtime `serve` (prompt terpisah).
664
+ * Logika difaktorkan ke fungsi module-level agar dapat di-unit-test dengan spy pada `warn`,
665
+ * tanpa menjalankan jalur generate penuh.
666
+ *
667
+ * @param {string} payloadDir - direktori payload (tempat dashboard + file SQL-nya)
668
+ * @param {(msg: string) => void} [warn=console.warn] - sink peringatan (diinjeksi untuk test)
669
+ * @returns {Array} daftar pelanggaran yang diperingatkan (untuk observabilitas/test)
670
+ */
671
+ function warnSoftDeleteDashboardDrift(payloadDir, warn = console.warn) {
672
+ let violations;
673
+ try {
674
+ violations = softDeleteDashboardGuard.scanDashboardsForViolations(payloadDir);
675
+ } catch (_err) {
676
+ // Scanner best-effort: kegagalan tak terduga TIDAK boleh menggagalkan generate.
677
+ return [];
678
+ }
679
+
680
+ for (const v of violations) {
681
+ const queryContext = v.key === null ? '' : ` (query '${v.key}')`;
682
+ warn(
683
+ `WARNING [soft-delete] dashboard '${v.dashboard}'${queryContext}: ` +
684
+ `${softDeleteDashboardGuard.formatViolationMessage(v)} ` +
685
+ `This dashboard was NOT regenerated by 'payload generate' and its module still serves the ` +
686
+ `leaking query; fix the widget and regenerate the dashboard.`
687
+ );
688
+ }
689
+
690
+ return violations;
691
+ }
692
+
693
+ /**
694
+ * Tentukan apakah tabel parent (`refTable`) adalah tabel soft-delete (R20 FK-awareness).
695
+ * Reuse deteksi name-based Phase 03: parent diakui soft-delete bila (a) ketiga kolom
696
+ * soft-delete (is_deleted/deleted_at/deleted_by) ADA, dan (b) CHECK konsistensi bernama
697
+ * `softDeleteCheckName(refTable, 63)` ADA. CHECK adalah penanda otoritatif (di-emit hanya
698
+ * oleh `schema migrate` dari SDF soft-delete valid), sehingga kombinasi kolom + CHECK cukup
699
+ * pasti tanpa perlu mengecek tipe ulang. Konservatif: ketidakpastian apa pun (introspeksi
700
+ * gagal, method tak tersedia, dialect non-PG) → `false` (cek eksistensi parent saja, R20 fallback).
701
+ *
702
+ * @param {Object} db - DatabaseIntrospector instance
703
+ * @param {string} qualifiedRefTable - nama parent ter-qualify (schema.table bila ada schema)
704
+ * @param {string} refTableName - nama parent TANPA schema (untuk derivasi nama CHECK)
705
+ * @returns {Promise<boolean>}
706
+ */
707
+ async function isParentSoftDelete(db, qualifiedRefTable, refTableName) {
708
+ try {
709
+ if (!db || typeof db.getColumns !== 'function' || typeof db.getCheckConstraints !== 'function') {
710
+ return false;
711
+ }
712
+ const cols = await db.getColumns(qualifiedRefTable);
713
+ const colSet = new Set((cols || []).map((c) => String(c).toLowerCase()));
714
+ const hasAllColumns = SOFT_DELETE_COLUMNS.every((c) => colSet.has(c));
715
+ if (!hasAllColumns) return false;
716
+
717
+ const checks = await db.getCheckConstraints(qualifiedRefTable);
718
+ const expectedName = softDeleteCheckName(refTableName, POSTGRES_MAX_IDENTIFIER_LENGTH);
719
+ return Array.isArray(checks) && checks.some((c) => c && c.name === expectedName);
720
+ } catch (err) {
721
+ return false;
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Derivasi daftar cek FK untuk restore (R20 FK-awareness) dari introspeksi DB. Untuk tiap
727
+ * foreign key tabel yang di-restore, simpan kolom lokal, tabel + kolom parent, dan status
728
+ * parent-soft-delete. Dikonsumsi template (`generateSoftDeleteRestoreOverride`) untuk meng-emit
729
+ * cek parent: parentSoftDelete=true → parent wajib aktif (is_deleted=FALSE, 422 bila tidak);
730
+ * false → cek eksistensi parent saja (422 bila hilang).
731
+ *
732
+ * Sumber FK list = `db.getForeignKeys` (tersedia di payload generate; TIDAK tersedia di jalur
733
+ * model generator yang murni RDF→kode). Hasil di-persist top-level di RDF (`softDeleteFkChecks`),
734
+ * pola yang sama dengan `uniqueConstraints` (sama-sama derived dari introspeksi DB di generate
735
+ * time, dikonsumsi template di codegen). Konservatif: introspeksi gagal / tidak ada FK → [].
736
+ *
737
+ * @param {Object} db - DatabaseIntrospector instance
738
+ * @param {string} tableName - nama tabel yang di-restore
739
+ * @returns {Promise<Array<{columns:string[], refTable:string, refColumns:string[], parentSoftDelete:boolean}>>}
740
+ */
741
+ async function deriveSoftDeleteFkChecks(db, tableName) {
742
+ if (!db || !db.pool || typeof db.getForeignKeys !== 'function') return [];
743
+
744
+ let foreignKeys;
745
+ try {
746
+ foreignKeys = await db.getForeignKeys(tableName);
747
+ } catch (err) {
748
+ return [];
749
+ }
750
+ if (!Array.isArray(foreignKeys) || foreignKeys.length === 0) return [];
751
+
752
+ const checks = [];
753
+ const parentCache = new Map();
754
+ for (const fk of foreignKeys) {
755
+ const ref = fk && fk.references;
756
+ if (!ref || !ref.table) continue;
757
+ const columns = Array.isArray(fk.columns) ? fk.columns.slice() : [];
758
+ const refColumns = Array.isArray(ref.columns) ? ref.columns.slice() : [];
759
+ if (columns.length === 0 || refColumns.length === 0 || columns.length !== refColumns.length) {
760
+ continue;
761
+ }
762
+ const qualifiedRefTable = ref.schema ? `${ref.schema}.${ref.table}` : ref.table;
763
+
764
+ let parentSoftDelete;
765
+ if (parentCache.has(qualifiedRefTable)) {
766
+ parentSoftDelete = parentCache.get(qualifiedRefTable);
767
+ } else {
768
+ parentSoftDelete = await isParentSoftDelete(db, qualifiedRefTable, ref.table);
769
+ parentCache.set(qualifiedRefTable, parentSoftDelete);
770
+ }
771
+
772
+ checks.push({ columns, refTable: qualifiedRefTable, refColumns, parentSoftDelete });
773
+ }
774
+ return checks;
775
+ }
776
+
777
+ /**
778
+ * Turunkan constraint `enum` RDF dari CHECK enum SDF (`checks` dengan op 'in'),
779
+ * secara in-place pada `payload.fieldValidation` (pola `applySoftDeleteDerivation`).
780
+ *
781
+ * Untuk tiap IR check `{ field, op: 'in', value: [...] }` pada model tabel, set
782
+ * `constraints.enum = value` pada entry fieldValidation string yang namanya cocok.
783
+ * Sumber = SDF IR, BUKAN introspeksi clause DB: deterministik dan agnostik dialect
784
+ * (PostgreSQL menulis ulang `IN (...)` menjadi `= ANY (ARRAY[...])`, sehingga clause
785
+ * DB tidak andal). Hilir: `payload migrate` (field-type-resolver Rule 5) merender
786
+ * field string ber-`enum` menjadi UDF `select` dengan dataSource static (combo box),
787
+ * paralel dengan boolean -> checkbox.
788
+ *
789
+ * Guard ketat agar baseline tabel tanpa enum tetap byte-identik:
790
+ * - Map SDF null (schema-path absen / gagal load) -> no-op
791
+ * - tabel tidak ada di SDF / bare-name ambigu -> no-op
792
+ * - check bukan `op:'in'` valid atau value kosong -> di-skip
793
+ * - field tidak punya entry fieldValidation string -> di-skip (lihat catatan)
794
+ * - `constraints.enum` sudah ada -> dipertahankan (tidak override)
795
+ *
796
+ * Catatan: field string nullable tanpa constraint lain di-skip dari fieldValidation
797
+ * oleh `generateFieldValidation` (anti-bloat), sehingga enum-nya tidak terpasang.
798
+ * Field enum pada praktiknya notnull (mis. `jabatan`), sehingga entry-nya selalu ada.
799
+ *
800
+ * @param {Object} payload - payloadData yang dimutasi
801
+ * @param {Map<string, Object>|null} models - Map model SDF (hasil loadSchemaMapGraceful)
802
+ * @param {string} tableName - nama tabel target
803
+ * @returns {void}
804
+ */
805
+ function applyCheckEnumDerivation(payload, models, tableName) {
806
+ if (!models) return;
807
+ if (!Array.isArray(payload.fieldValidation)) return;
808
+
809
+ let ir;
810
+ try {
811
+ ir = findModelByTable(models, tableName);
812
+ } catch (err) {
813
+ return;
814
+ }
815
+ if (!ir || !Array.isArray(ir.checks)) return;
816
+
817
+ for (const check of ir.checks) {
818
+ if (!check || check._invalid || check.op !== 'in') continue;
819
+ if (typeof check.field !== 'string') continue;
820
+ if (!Array.isArray(check.value) || check.value.length === 0) continue;
821
+
822
+ const fv = payload.fieldValidation.find(
823
+ (f) => f && f.name === check.field && f.type === 'string'
824
+ );
825
+ if (!fv || !fv.constraints || typeof fv.constraints !== 'object') continue;
826
+ if (Array.isArray(fv.constraints.enum)) continue;
827
+
828
+ fv.constraints.enum = check.value.slice();
829
+ }
830
+ }
831
+
832
+ // ============================================================================
833
+ // FK-SD-4 REGISTRY DERIVATION (SDF scan -> RDF) — Fase 1.5 FK-aware soft-delete
834
+ // ============================================================================
835
+ //
836
+ // Menurunkan dua field RDF top-level baru saat `payload generate`, fondasi data untuk
837
+ // FK-SD-1 (forward block), FK-SD-2 (restrict), FK-SD-3 (cascade). Phase ini TIDAK
838
+ // mengubah behavior handler; hanya derivasi + persist metadata.
839
+ //
840
+ // Sumber metadata = SDF scan (Map model `loadSchemaPath`), deterministik dan tanpa
841
+ // query DB (keputusan terkunci Pasca-Discovery). Berbeda dengan jalur restore R20
842
+ // (`deriveSoftDeleteFkChecks`, introspeksi DB) yang TIDAK diubah di sini.
843
+
844
+ /**
845
+ * Normalisasi nilai `onDelete` IR (camelCase) ke semantik FK-SD (keputusan terkunci Q5):
846
+ * - 'cascade' -> 'cascade'
847
+ * - 'setNull' -> 'setNull' (pass-through; ditolak validator Phase 05, bukan di sini)
848
+ * - 'restrict' / 'noAction' / absent -> 'restrict' (default blok)
849
+ *
850
+ * Nilai di luar whitelist (`cascade`/`restrict`/`setNull`/`noAction`) sudah ditolak
851
+ * schema-validator (validateRelations), sehingga input ke fungsi ini dijamin salah satu
852
+ * dari empat itu atau absent (undefined/null). Fallback konservatif ke 'restrict' (blok).
853
+ *
854
+ * @param {string|undefined|null} onDelete - nilai onDelete dari IR relasi
855
+ * @returns {'cascade'|'restrict'|'setNull'}
856
+ */
857
+ function normalizeFkOnDelete(onDelete) {
858
+ if (onDelete === 'cascade') return 'cascade';
859
+ if (onDelete === 'setNull') return 'setNull';
860
+ return 'restrict';
861
+ }
862
+
863
+ /**
864
+ * Muat Map model SDF secara graceful untuk SDF scan FK-SD-4. Berbeda dengan
865
+ * `resolveSoftDeleteForTable` (yang ERROR bila schema-path absen, R12), derivasi registry
866
+ * FK TIDAK boleh menggagalkan generate. Tiga kondisi -> kembalikan null (derivasi kosong):
867
+ * - `--schema-path` absen/kosong (tabel non-soft-delete tak wajib schema-path)
868
+ * - `loadSchemaPath` gagal (folder/SDF invalid)
869
+ * - mode single-file: tetap memuat 1 model, tetapi scan lintas-tabel natural kosong
870
+ * (parent/anak tabel lain tidak ada di Map) -> field dihilangkan secara graceful.
871
+ *
872
+ * @param {string|null|undefined} schemaPath - nilai flag --schema-path
873
+ * @returns {Map<string, Object>|null} Map model SDF, atau null bila tak tersedia
874
+ */
875
+ function loadSchemaMapGraceful(schemaPath) {
876
+ if (typeof schemaPath !== 'string' || schemaPath.trim() === '') return null;
877
+ try {
878
+ return loadSchemaPath(schemaPath);
879
+ } catch (err) {
880
+ return null;
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Resolusi IR model parent yang dirujuk sebuah relasi `belongsTo`. `rel.target` konvensinya
886
+ * adalah nama tabel target: `schema introspect` menamai relasi = nama tabel target, dan bila
887
+ * `target` eksplisit absen IR fallback ke nama relasi (`ir-builder.js:198`), sehingga relasi
888
+ * introspeksi tetap menunjuk tabel yang benar. `findModelByTable` menerima nama bare maupun
889
+ * qualified. Konservatif: target tak cocok model mana pun -> null; bare-name ambigu (>1
890
+ * schema, findModelByTable melempar) -> null.
891
+ *
892
+ * @param {Map<string, Object>} models - Map model SDF
893
+ * @param {Object} rel - relasi IR (harus belongsTo)
894
+ * @returns {Object|null} IR model parent, atau null
895
+ */
896
+ function resolveBelongsToParent(models, rel) {
897
+ if (!rel || rel.type !== 'belongsTo') return null;
898
+ const target = rel.target;
899
+ if (typeof target !== 'string' || target === '') return null;
900
+ try {
901
+ return findModelByTable(models, target);
902
+ } catch (err) {
903
+ return null;
904
+ }
905
+ }
906
+
907
+ // Komparator determinisme snapshot (independen urutan deklarasi/urutan file loader).
908
+ function compareFkParent(a, b) {
909
+ const ka = `${a.refTable}|${a.columns.join(',')}`;
910
+ const kb = `${b.refTable}|${b.columns.join(',')}`;
911
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
912
+ }
913
+ function compareFkChild(a, b) {
914
+ const ka = `${a.childTable}|${a.childColumns.join(',')}`;
915
+ const kb = `${b.childTable}|${b.childColumns.join(',')}`;
916
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
917
+ }
918
+
919
+ /**
920
+ * FK-SD-4 FORWARD registry (`softDeleteFkParents`). Untuk tabel `tableName`, daftar FK
921
+ * outbound (relasi `belongsTo`) yang parent-nya soft-delete-enabled (SDF `softDelete.enabled
922
+ * === true`). Dikonsumsi FK-SD-1 (forward block create/update anak ke parent terhapus).
923
+ *
924
+ * Berlaku untuk SEMUA tabel, TERMASUK tabel anak yang bukan soft-delete (mis. `visitors`):
925
+ * hanya FK ke parent soft-delete yang masuk. Shape per-entry sengaja subset
926
+ * `softDeleteFkChecks` (tanpa `parentSoftDelete`, karena di sini setiap entry pasti
927
+ * soft-delete) agar emit FK-SD-1 dapat memakai ulang pola SQL parent-aktif restore (R20):
928
+ * { columns: [localKey], refTable: <qualified>, refColumns: [references] }
929
+ *
930
+ * `localKey`/`references` dijamin string tunggal oleh validator (validateRelations
931
+ * mewajibkan keduanya string), sehingga selalu single-column array. Graceful empty bila
932
+ * Map/IR/target tidak tersedia (single-file mode, schema-path absen).
933
+ *
934
+ * @param {Map<string, Object>|null} models - Map model SDF (null -> [])
935
+ * @param {string} tableName - nama tabel (bare atau schema.table)
936
+ * @returns {Array<{columns:string[], refTable:string, refColumns:string[]}>}
937
+ */
938
+ function deriveSoftDeleteFkParents(models, tableName) {
939
+ if (!models) return [];
940
+ let ir;
941
+ try {
942
+ ir = findModelByTable(models, tableName);
943
+ } catch (err) {
944
+ return [];
945
+ }
946
+ if (!ir || !ir.relations) return [];
947
+
948
+ const parents = [];
949
+ for (const rel of Object.values(ir.relations)) {
950
+ if (!rel || rel.type !== 'belongsTo') continue;
951
+ if (typeof rel.localKey !== 'string' || typeof rel.references !== 'string') continue;
952
+ const parentModel = resolveBelongsToParent(models, rel);
953
+ if (!parentModel || !isSoftDeleteEnabled(parentModel)) continue;
954
+ parents.push({
955
+ columns: [rel.localKey],
956
+ refTable: parentModel.qualifiedName || parentModel.tableName,
957
+ refColumns: [rel.references]
958
+ });
959
+ }
960
+ parents.sort(compareFkParent);
961
+ return parents;
962
+ }
963
+
964
+ /**
965
+ * FK-SD-4 REVERSE registry (`softDeleteFkChildren`). Untuk tabel master `tableName`, pindai
966
+ * SELURUH model lain di Map: tiap relasi `belongsTo` yang target-nya = master ini berarti
967
+ * tabel pemilik relasi adalah anak. Rekam tabel anak, kolom FK lokal anak, `onDelete`
968
+ * (dinormalisasi Q5), dan status soft-delete anak. Dikonsumsi FK-SD-2 (restrict) & FK-SD-3
969
+ * (cascade).
970
+ *
971
+ * Hanya relasi `belongsTo` yang diperhitungkan: FK fisik selalu berada di tabel anak yang
972
+ * mendeklarasikannya sebagai `belongsTo` (introspeksi DB menghasilkan FK hanya pada child).
973
+ * `hasMany`/`hasOne` adalah view logis inverse dari FK yang sama (kolom-nya bukan FK fisik
974
+ * di tabel pendeklarasi), sehingga TIDAK dipakai agar tidak salah-arah/duplikat. Pencocokan
975
+ * master dilakukan dengan me-resolve target relasi ke model lalu membandingkan qualifiedName
976
+ * (robust terhadap target bare vs qualified). Self-reference (anak == master) sah dan
977
+ * disertakan. Pemanggil HARUS hanya memanggil ini untuk tabel soft-delete (guard
978
+ * hasSoftDeleteColumns); fungsi sendiri tidak menambah guard itu.
979
+ *
980
+ * `parentColumn` (= `rel.references`) membawa kolom MASTER yang dirujuk FK anak. FK-SD-2/3
981
+ * mem-query anak dengan `WHERE <childColumn> = <nilai parentColumn master row>`; mengandalkan
982
+ * PK master salah bila FK merujuk kolom non-PK. Diambil dari relasi, BUKAN diasumsikan = PK.
983
+ * Sejalan forward derivation, `localKey`/`references` wajib string tunggal (composite ditolak
984
+ * validator); entry tanpa keduanya string di-skip (defensif, konsisten determinisme snapshot).
985
+ *
986
+ * @param {Map<string, Object>|null} models - Map model SDF (null -> [])
987
+ * @param {string} tableName - nama tabel master (bare atau schema.table)
988
+ * @returns {Array<{childTable:string, childColumns:string[], parentColumn:string, onDelete:string, childSoftDelete:boolean}>}
989
+ */
990
+ function deriveSoftDeleteFkChildren(models, tableName) {
991
+ if (!models) return [];
992
+ let masterIr;
993
+ try {
994
+ masterIr = findModelByTable(models, tableName);
995
+ } catch (err) {
996
+ return [];
997
+ }
998
+ if (!masterIr) return [];
999
+ const masterQualified = masterIr.qualifiedName || masterIr.tableName;
1000
+
1001
+ const children = [];
1002
+ for (const childModel of models.values()) {
1003
+ if (!childModel || !childModel.relations) continue;
1004
+ for (const rel of Object.values(childModel.relations)) {
1005
+ if (!rel || rel.type !== 'belongsTo') continue;
1006
+ if (typeof rel.localKey !== 'string' || typeof rel.references !== 'string') continue;
1007
+ const parentModel = resolveBelongsToParent(models, rel);
1008
+ if (!parentModel) continue;
1009
+ if ((parentModel.qualifiedName || parentModel.tableName) !== masterQualified) continue;
1010
+ children.push({
1011
+ childTable: childModel.qualifiedName || childModel.tableName,
1012
+ childColumns: [rel.localKey],
1013
+ parentColumn: rel.references,
1014
+ onDelete: normalizeFkOnDelete(rel.onDelete),
1015
+ childSoftDelete: isSoftDeleteEnabled(childModel)
1016
+ });
1017
+ }
1018
+ }
1019
+ children.sort(compareFkChild);
1020
+ return children;
1021
+ }
1022
+
1023
+ /**
1024
+ * FK-SD-3 (Fase 1.5) — derivasi closure cascade transitif untuk master soft-delete.
1025
+ * Mengadopsi algoritma spike Phase 04s (`buildCascadeClosure`, terbukti deterministik +
1026
+ * cycle-safe) menjadi fungsi produksi. Dari `masterTable`, hitung peta node (master +
1027
+ * SELURUH keturunan yang reachable lewat edge `onDelete:cascade` ke anak soft-delete),
1028
+ * masing-masing membawa metadata yang dibutuhkan walk runtime (Phase 04b) untuk
1029
+ * men-soft-delete dirinya + menjalar lebih dalam. Closure dipersist PENUH di RDF master
1030
+ * (keputusan orchestrator) agar template Phase 04b tetap Map-free: tidak ada lookup
1031
+ * lintas-RDF/akses DB saat codegen.
1032
+ *
1033
+ * Tiap node menyimpan:
1034
+ * - table : nama qualified node
1035
+ * - pk : primary key node (string bila single-column, array bila composite)
1036
+ * dari IR `primaryKey`
1037
+ * - reusable : [{field, length}] dari IR `softDelete.reusable` node ([] bila tidak ada)
1038
+ * - children : SELURUH anak langsung (cascade + restrict + setNull), tiap entry
1039
+ * { childTable, childColumn, parentColumn, onDelete, childSoftDelete }.
1040
+ * Anak restrict/setNull DIREKAM (agar restrict-per-node Phase 04c bisa
1041
+ * cek di SETIAP node, bukan hanya master) tetapi TIDAK memicu rekursi.
1042
+ *
1043
+ * Aturan expand (rekursi): HANYA ke `childTable` dari edge `onDelete==='cascade'` yang
1044
+ * `childSoftDelete===true`. Edge cascade ke anak NON-soft-delete direkam di `children`
1045
+ * (`childSoftDelete:false`) tetapi TIDAK di-expand dan TIDAK error di sini (konfigurasi
1046
+ * itu ditolak validator Phase 05). Edge restrict/setNull direkam tetapi tidak di-expand.
1047
+ *
1048
+ * Cycle-safe (level closure): `visited` set berisi qualified table name; tabel yang sudah
1049
+ * masuk `nodes` tidak di-expand ulang, sehingga closure pasti terminasi pada graf
1050
+ * ber-cycle (self-reference, A→B→A) maupun diamond (anak dengan ≥2 parent cascade diproses
1051
+ * sekali). `parentColumn = rel.references` (BUKAN diasumsikan PK), konsisten Phase 03.
1052
+ *
1053
+ * `order` = urutan kunjungan node DFS pre-order dengan anak cascade dikunjungi menurut
1054
+ * urutan sorted `deriveSoftDeleteFkChildren` (master lebih dulu, lalu descendant). Stabil
1055
+ * dan deterministik untuk snapshot RDF.
1056
+ *
1057
+ * Graceful: Map null/parsial (single-file, `--schema-path` absen) → `deriveSoftDeleteFkChildren`
1058
+ * mengembalikan [] sehingga tree berisi hanya node master tanpa children, konsisten Phase 01.
1059
+ * (Pemanggil men-guard persist dengan ≥1 anak cascade, sehingga kasus graceful itu tidak
1060
+ * mempersist field sama sekali.)
1061
+ *
1062
+ * @param {Map<string, Object>|null} models - Map model SDF
1063
+ * @param {string} masterTable - nama tabel master (bare atau schema.table)
1064
+ * @returns {{ master:string, order:string[], nodes: Object<string, {table:string, pk:(string|string[]), reusable:Array<{field:string,length:number}>, children:Array<{childTable:string,childColumn:string,parentColumn:string,onDelete:string,childSoftDelete:boolean}>}> }}
1065
+ */
1066
+ function buildSoftDeleteCascadeTree(models, masterTable) {
1067
+ let masterIr = null;
1068
+ if (models) {
1069
+ try {
1070
+ masterIr = findModelByTable(models, masterTable);
1071
+ } catch (err) {
1072
+ masterIr = null;
1073
+ }
1074
+ }
1075
+ // Map tak tersedia / master tak ada di Map (single-file, schema-path absen): pakai nama
1076
+ // tabel apa adanya. directChildren akan kosong (deriveSoftDeleteFkChildren graceful),
1077
+ // menghasilkan tree minimal (hanya master, tanpa expand). Konsisten Phase 01.
1078
+ const masterQ = masterIr
1079
+ ? (masterIr.qualifiedName || masterIr.tableName)
1080
+ : masterTable;
1081
+
1082
+ const nodes = {};
1083
+ const order = [];
1084
+ const visited = new Set();
1085
+ const stack = [masterQ];
1086
+
1087
+ while (stack.length) {
1088
+ const tableQ = stack.pop();
1089
+ if (visited.has(tableQ)) continue; // cycle/diamond guard: tabel sudah diproses
1090
+ visited.add(tableQ);
1091
+
1092
+ const ir = models ? models.get(tableQ) : null;
1093
+ const pkArr = (ir && Array.isArray(ir.primaryKey)) ? ir.primaryKey : [];
1094
+ const reusable = (ir && ir.softDelete && Array.isArray(ir.softDelete.reusable))
1095
+ ? ir.softDelete.reusable.map((r) => ({ field: r.field, length: r.length }))
1096
+ : [];
1097
+
1098
+ // Anak langsung — derivasi REVERSE registry ASLI (Phase 01/03), sudah membawa
1099
+ // childColumns/parentColumn/onDelete/childSoftDelete dan tersortir deterministik.
1100
+ const directChildren = deriveSoftDeleteFkChildren(models, tableQ);
1101
+
1102
+ nodes[tableQ] = {
1103
+ table: tableQ,
1104
+ pk: pkArr.length === 1 ? pkArr[0] : pkArr.slice(),
1105
+ reusable,
1106
+ children: directChildren.map((c) => ({
1107
+ childTable: c.childTable,
1108
+ childColumn: c.childColumns[0],
1109
+ parentColumn: c.parentColumn,
1110
+ onDelete: c.onDelete,
1111
+ childSoftDelete: c.childSoftDelete
1112
+ }))
1113
+ };
1114
+ order.push(tableQ);
1115
+
1116
+ // Expand HANYA edge cascade ke anak soft-delete-enabled. Push REVERSE agar pop dalam
1117
+ // urutan sorted (DFS pre-order stabil). Cascade→non-SD & restrict/setNull TIDAK di-expand.
1118
+ const toExpand = [];
1119
+ for (const c of directChildren) {
1120
+ if (c.onDelete !== 'cascade' || !c.childSoftDelete) continue;
1121
+ if (visited.has(c.childTable)) continue;
1122
+ toExpand.push(c.childTable);
1123
+ }
1124
+ for (let i = toExpand.length - 1; i >= 0; i--) stack.push(toExpand[i]);
1125
+ }
1126
+
1127
+ return { master: masterQ, order, nodes };
1128
+ }
1129
+
490
1130
  // ============================================================================
491
1131
  // ADVISORIES (informational) — untuk payload validate
492
1132
  // ============================================================================
@@ -1115,6 +1755,13 @@ class PayloadGenerator {
1115
1755
  console.log(`Table: ${payloadData.tableName}`);
1116
1756
  console.log(`Primary Key: ${payloadData.primaryKey}`);
1117
1757
 
1758
+ // Flag kehadiran kolom soft-delete di DB (R12). DITANGKAP dari daftar kolom DB
1759
+ // SEBELUM strip fieldName, supaya resolveSoftDeleteForTable/derivation (~1786)
1760
+ // tetap jalan dan tetap ERROR bila kolom hadir di DB tetapi SDF tidak punya blok
1761
+ // softDelete valid. Strip middle-ground beroperasi pada PROYEKSI fieldName, bukan
1762
+ // pada daftar kolom DB yang dipakai cek konsistensi.
1763
+ let hasSoftDeleteColumns = false;
1764
+
1118
1765
  // Get fields from database
1119
1766
  if (this.db.pool) {
1120
1767
  const columns = await this.db.getColumns(args.table);
@@ -1130,6 +1777,22 @@ class PayloadGenerator {
1130
1777
  payloadData.auditColumns = false;
1131
1778
  console.log('No audit columns detected in table: setting auditColumns: false');
1132
1779
  }
1780
+
1781
+ // Soft-delete middle-ground (audit-parity): keluarkan tiga kolom soft-delete
1782
+ // (is_deleted/deleted_at/deleted_by) dari PROYEKSI fieldName agar RDF bersih.
1783
+ // Seluruh turunan (datatablesQuery SQL, datatablesWhere, dateTimeFields,
1784
+ // fieldValidation) diturunkan dari fieldName SETELAH titik ini sehingga ikut
1785
+ // bersih otomatis. validFields MODEL tetap memuat tiga kolom (di-append di
1786
+ // template) supaya mekanisme visibility R15 tetap lolos guard BaseModel.
1787
+ // Flag ditangkap dari kolom DB (bukan fieldName yang sudah di-strip).
1788
+ hasSoftDeleteColumns = SOFT_DELETE_COLUMNS.some(col => columns.includes(col));
1789
+ if (hasSoftDeleteColumns) {
1790
+ const softDeleteStripped = payloadData.fieldName.filter(col => SOFT_DELETE_COLUMNS.includes(col));
1791
+ payloadData.fieldName = payloadData.fieldName.filter(col => !SOFT_DELETE_COLUMNS.includes(col));
1792
+ if (softDeleteStripped.length > 0) {
1793
+ console.log(`Soft-delete columns excluded from RDF fieldName: ${softDeleteStripped.join(', ')}`);
1794
+ }
1795
+ }
1133
1796
  }
1134
1797
  }
1135
1798
 
@@ -1222,6 +1885,85 @@ class PayloadGenerator {
1222
1885
  .map((c) => ({ name: c.name, fields: c.columns }));
1223
1886
  }
1224
1887
 
1888
+ // Soft-delete derivation (R12/R13): bila tabel memiliki satu atau lebih kolom
1889
+ // soft-delete (is_deleted/deleted_at/deleted_by), wajib resolusi SDF dengan blok
1890
+ // softDelete valid lalu turunkan blok ke RDF (+ override maxLength reusable = base).
1891
+ // Guard ketat: tabel non-soft-delete tidak menyentuh jalur ini sama sekali, sehingga
1892
+ // output byte-identik baseline (--schema-path diabaikan untuk tabel non-soft-delete).
1893
+ // CATATAN: `hasSoftDeleteColumns` di-capture dari daftar kolom DB SEBELUM strip
1894
+ // fieldName (lihat ~1662), karena fieldName kini sudah tidak memuat tiga kolom.
1895
+
1896
+ // FK-SD-4 (Fase 1.5): muat Map SDF SEKALI secara graceful untuk derivasi registry FK
1897
+ // (forward `softDeleteFkParents` + reverse `softDeleteFkChildren`). Berbeda dengan
1898
+ // resolveSoftDeleteForTable yang ERROR bila schema-path absen (R12), registry FK tidak
1899
+ // boleh menggagalkan generate: Map null -> derivasi kosong (field dihilangkan secara
1900
+ // graceful). Tidak menambah query DB saat runtime (derivasi hanya saat generate).
1901
+ const fkRegistryModels = loadSchemaMapGraceful(args['schema-path']);
1902
+
1903
+ // Enum derivation (CHECK `in` SDF -> constraints.enum RDF): bila tabel punya
1904
+ // CHECK enum di SDF, turunkan daftar nilai ke fieldValidation string sehingga
1905
+ // hilir `payload migrate` merendernya sebagai UDF select static (combo box).
1906
+ // Guarded + graceful (Map null / tabel tak ada / field non-string -> no-op),
1907
+ // sehingga baseline tabel tanpa enum tetap byte-identik.
1908
+ applyCheckEnumDerivation(payloadData, fkRegistryModels, payloadData.tableName);
1909
+
1910
+ if (hasSoftDeleteColumns) {
1911
+ const sdfSoftDelete = resolveSoftDeleteForTable(payloadData.tableName, args['schema-path']);
1912
+ applySoftDeleteDerivation(payloadData, sdfSoftDelete);
1913
+ const reusableCount = Array.isArray(sdfSoftDelete.reusable)
1914
+ ? sdfSoftDelete.reusable.length
1915
+ : 0;
1916
+
1917
+ // FK-awareness (R20): derive daftar cek FK + status parent-soft-delete dari introspeksi
1918
+ // DB. Top-level (pola uniqueConstraints), guarded oleh hasSoftDeleteColumns → tabel
1919
+ // non-soft-delete tidak menyentuh field ini (diff-zero baseline terjaga). Dikonsumsi
1920
+ // template restore (R20) saat action.restore aktif. TIDAK diubah Fase 1.5.
1921
+ payloadData.softDeleteFkChecks = await deriveSoftDeleteFkChecks(this.db, payloadData.tableName);
1922
+ const softDeleteParentCount = payloadData.softDeleteFkChecks.filter((c) => c.parentSoftDelete).length;
1923
+
1924
+ // FK-SD-4 REVERSE registry (Fase 1.5): anak yang menunjuk master ini, untuk FK-SD-2
1925
+ // (restrict) & FK-SD-3 (cascade). Persist SELALU untuk tabel soft-delete (boleh []),
1926
+ // mengikuti preseden softDeleteFkChecks. Diturunkan via SDF scan (Map), bukan DB.
1927
+ payloadData.softDeleteFkChildren = deriveSoftDeleteFkChildren(fkRegistryModels, payloadData.tableName);
1928
+
1929
+ // FK-SD-3 (Fase 1.5, Phase 04a/04b): closure cascade transitif (master + seluruh keturunan
1930
+ // reachable lewat edge cascade ke anak soft-delete). Persist PENUH di RDF master agar
1931
+ // walk Phase 04b Map-free. Guard: HANYA bila master punya ≥1 anak langsung
1932
+ // onDelete:cascade YANG anaknya soft-delete-enabled (childSoftDelete === true) — sebab
1933
+ // softDeleteCascadeTree hanya bermakna bila ada cascade NYATA yang bisa di-walk. Edge
1934
+ // cascade→non-soft-delete adalah config invalid (ditolak validator Phase 05) → tidak
1935
+ // mempersist tree degenerate. Master tanpa cascade expandable → field DIHILANGKAN
1936
+ // (diff-zero baseline + komposisi Phase 03 restrict-only tetap utuh). Penempatan tepat
1937
+ // setelah softDeleteFkChildren → urutan key RDF deterministik.
1938
+ const hasCascadeChild = payloadData.softDeleteFkChildren.some(
1939
+ (c) => c.onDelete === 'cascade' && c.childSoftDelete === true
1940
+ );
1941
+ let cascadeNodeCount = 0;
1942
+ if (hasCascadeChild) {
1943
+ payloadData.softDeleteCascadeTree =
1944
+ buildSoftDeleteCascadeTree(fkRegistryModels, payloadData.tableName);
1945
+ cascadeNodeCount = payloadData.softDeleteCascadeTree.order.length;
1946
+ }
1947
+
1948
+ console.log(
1949
+ `Soft-delete detected: derived softDelete block from SDF ` +
1950
+ `('${args['schema-path']}', ${reusableCount} reusable field(s); ` +
1951
+ `${payloadData.softDeleteFkChecks.length} FK check(s), ${softDeleteParentCount} soft-delete parent(s); ` +
1952
+ `${payloadData.softDeleteFkChildren.length} reverse child(ren)` +
1953
+ (hasCascadeChild ? `; cascade closure ${cascadeNodeCount} node(s)` : '') + `).`
1954
+ );
1955
+ }
1956
+
1957
+ // FK-SD-4 FORWARD registry (Fase 1.5): FK outbound tabel ini ke parent soft-delete, untuk
1958
+ // FK-SD-1 (forward block create/update). Berlaku untuk SEMUA tabel TERMASUK anak
1959
+ // non-soft-delete (mis. visitors), sehingga jalur ini di LUAR guard hasSoftDeleteColumns.
1960
+ // Persist HANYA bila array non-kosong → tabel netral (tanpa softDelete & tanpa FK ke
1961
+ // tabel soft-delete) tetap byte-identik baseline (diff-zero terjaga).
1962
+ const softDeleteFkParents = deriveSoftDeleteFkParents(fkRegistryModels, payloadData.tableName);
1963
+ if (softDeleteFkParents.length > 0) {
1964
+ payloadData.softDeleteFkParents = softDeleteFkParents;
1965
+ }
1966
+
1225
1967
  // Save payload
1226
1968
  this.ensureOutputDir();
1227
1969
  const filename = baseFilename + '.json';
@@ -1239,6 +1981,15 @@ class PayloadGenerator {
1239
1981
  console.log();
1240
1982
  console.log(`Payload saved: ${outputPath}`);
1241
1983
  console.log(`Query saved: ${sqlOutputPath}`);
1984
+
1985
+ // Checkpoint 2 soft-delete dashboard guard (WARNING, non-blocking): bila RDF yang baru
1986
+ // ditulis adalah tabel soft-delete, scan dashboard yang sudah ada di outputDir terhadap
1987
+ // registry soft-delete saat ini lalu peringatkan dashboard yang bocor. Gating
1988
+ // isSoftDeleteEnabled memastikan tabel netral tidak memicu scan (no noise, no regresi).
1989
+ // Tidak boleh menggagalkan generate (fungsi warn bersifat best-effort, tidak throw).
1990
+ if (isSoftDeleteEnabled(payloadData)) {
1991
+ warnSoftDeleteDashboardDrift(this.outputDir);
1992
+ }
1242
1993
  }
1243
1994
  }
1244
1995
 
@@ -1863,6 +2614,14 @@ class SchemaValidator {
1863
2614
  enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
1864
2615
  const stringColumns = collectStringColumns(enrichedColumns);
1865
2616
 
2617
+ // Soft-delete middle-ground (audit-parity): bila blok softDelete dipertahankan di
2618
+ // RDF (lewat spread ...oldPayload), keluarkan tiga kolom soft-delete dari fieldName
2619
+ // hasil sync sama seperti jalur generate. Gate dari oldPayload (blok softDelete tidak
2620
+ // berubah saat sync). Tabel non-soft-delete: gate false → newFieldName byte-identik
2621
+ // baseline. Strip di KEDUA loop agar RDF lama (yang masih memuat tiga kolom di
2622
+ // fieldName) ikut dibersihkan secara idempoten saat sync.
2623
+ const stripSoftDelete = isSoftDeleteEnabled(oldPayload);
2624
+
1866
2625
  // Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
1867
2626
  // Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
1868
2627
  // dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
@@ -1870,6 +2629,7 @@ class SchemaValidator {
1870
2629
  const newFieldName = [];
1871
2630
  // Pertahankan field lama yang masih ada di database
1872
2631
  for (const field of oldPayload.fieldName) {
2632
+ if (stripSoftDelete && SOFT_DELETE_COLUMNS.includes(field)) continue;
1873
2633
  if (dbColumns.includes(field)) {
1874
2634
  newFieldName.push(field);
1875
2635
  }
@@ -1878,6 +2638,7 @@ class SchemaValidator {
1878
2638
  for (const col of dbColumns) {
1879
2639
  if (newFieldName.includes(col)) continue;
1880
2640
  if (DEFAULT_AUDIT_COLUMNS.includes(col)) continue;
2641
+ if (stripSoftDelete && SOFT_DELETE_COLUMNS.includes(col)) continue;
1881
2642
  newFieldName.push(col);
1882
2643
  }
1883
2644
 
@@ -2056,5 +2817,17 @@ module.exports = {
2056
2817
  collectStringColumns,
2057
2818
  isDefaultSearchableColumn,
2058
2819
  applyIsActiveDefaultScope,
2059
- buildPayloadAdvisories
2820
+ applySoftDeleteDerivation,
2821
+ applyCheckEnumDerivation,
2822
+ resolveSoftDeleteForTable,
2823
+ deriveSoftDeleteFkChecks,
2824
+ isParentSoftDelete,
2825
+ findModelByTable,
2826
+ loadSchemaMapGraceful,
2827
+ normalizeFkOnDelete,
2828
+ deriveSoftDeleteFkParents,
2829
+ deriveSoftDeleteFkChildren,
2830
+ buildSoftDeleteCascadeTree,
2831
+ buildPayloadAdvisories,
2832
+ warnSoftDeleteDashboardDrift
2060
2833
  };