@restforgejs/platform 5.1.7 → 5.1.16

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 (201) 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/payload/generate.js +10 -2
  7. package/generators/cli/schema/apply.js +6 -1
  8. package/generators/cli/schema/diff.js +6 -1
  9. package/generators/cli/schema/introspect.js +32 -11
  10. package/generators/lib/data/db-executor.js +8 -8
  11. package/generators/lib/data/envelope.js +3 -3
  12. package/generators/lib/dbschema-kit/apply-engine.js +20 -0
  13. package/generators/lib/dbschema-kit/dialect/mysql.js +2 -0
  14. package/generators/lib/dbschema-kit/dialect/oracle.js +2 -0
  15. package/generators/lib/dbschema-kit/dialect/postgres.js +4 -0
  16. package/generators/lib/dbschema-kit/dialect/sqlite.js +5 -0
  17. package/generators/lib/dbschema-kit/diff-engine.js +22 -1
  18. package/generators/lib/dbschema-kit/diff-reporter.js +293 -272
  19. package/generators/lib/dbschema-kit/emitters/create-index.js +23 -1
  20. package/generators/lib/dbschema-kit/emitters/create-table.js +48 -0
  21. package/generators/lib/dbschema-kit/introspect-mapper.js +154 -2
  22. package/generators/lib/dbschema-kit/ir-builder.js +84 -1
  23. package/generators/lib/dbschema-kit/schema-printer.js +20 -0
  24. package/generators/lib/dbschema-kit/soft-delete-constants.js +111 -0
  25. package/generators/lib/dbschema-kit/validator/schema-validator.js +231 -0
  26. package/generators/lib/generators/processor-validation-generator.js +16 -16
  27. package/generators/lib/payload/payload-runner.js +711 -1
  28. package/generators/lib/payload/schema-diff.js +7 -0
  29. package/generators/lib/templates/dashboard-catalog.js +1 -1
  30. package/generators/lib/templates/db-connection-env.js +1 -1
  31. package/generators/lib/templates/dbschema-catalog.js +1 -1
  32. package/generators/lib/templates/field-validation-catalog.js +1 -1
  33. package/generators/lib/templates/mysql-template.js +1 -1
  34. package/generators/lib/templates/oracle-template.js +1 -1
  35. package/generators/lib/templates/postgres-template.js +1 -1
  36. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  37. package/generators/lib/templates/sqlite-template.js +1 -1
  38. package/generators/lib/utils/database-introspector.js +48 -0
  39. package/generators/lib/utils/env-manager.js +4 -4
  40. package/generators/lib/utils/file-utils.js +6 -6
  41. package/generators/lib/utils/payload-processor.js +18 -2
  42. package/generators/lib/validators/argument-validator.js +2 -2
  43. package/generators/lib/validators/dashboard-validator.js +35 -1
  44. package/generators/lib/validators/payload-validator.js +460 -33
  45. package/integrity-manifest.json +20 -20
  46. package/package.json +2 -1
  47. package/scripts/verify-integrity.js +1 -1
  48. package/server.js +1 -1
  49. package/src/components/handlers/adjust_handler.js +1 -1
  50. package/src/components/handlers/audit_handler.js +1 -1
  51. package/src/components/handlers/delete_handler.js +1 -1
  52. package/src/components/handlers/export_handler.js +1 -1
  53. package/src/components/handlers/import_handler.js +1 -1
  54. package/src/components/handlers/insert_handler.js +1 -1
  55. package/src/components/handlers/update_handler.js +1 -1
  56. package/src/components/handlers/upload_handler.js +1 -1
  57. package/src/components/handlers/workflow_handler.js +1 -1
  58. package/src/components/integrations/webhook.js +1 -1
  59. package/src/consumers/baseConsumer.js +1 -1
  60. package/src/consumers/declarativeMapper.js +1 -1
  61. package/src/consumers/handlers/apiHandler.js +1 -1
  62. package/src/consumers/handlers/consoleHandler.js +1 -1
  63. package/src/consumers/handlers/databaseHandler.js +1 -1
  64. package/src/consumers/handlers/index.js +1 -1
  65. package/src/consumers/handlers/kafkaHandler.js +1 -1
  66. package/src/consumers/index.js +1 -1
  67. package/src/consumers/messageTransformer.js +1 -1
  68. package/src/consumers/validator.js +1 -1
  69. package/src/core/db/dialect/base-dialect.js +1 -1
  70. package/src/core/db/dialect/index.js +1 -1
  71. package/src/core/db/dialect/mysql-dialect.js +1 -1
  72. package/src/core/db/dialect/oracle-dialect.js +1 -1
  73. package/src/core/db/dialect/postgres-dialect.js +1 -1
  74. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  75. package/src/core/db/flatten-helper.js +1 -1
  76. package/src/core/db/query-builder-error.js +1 -1
  77. package/src/core/db/query-builder.js +1 -1
  78. package/src/core/db/relation-helper.js +1 -1
  79. package/src/core/handlers/delete_handler.js +1 -1
  80. package/src/core/handlers/insert_handler.js +1 -1
  81. package/src/core/handlers/update_handler.js +1 -1
  82. package/src/core/models/base-model.js +1 -1
  83. package/src/core/utils/cache-manager.js +1 -1
  84. package/src/core/utils/component-engine.js +1 -1
  85. package/src/core/utils/context-builder.js +1 -1
  86. package/src/core/utils/datetime-formatter.js +1 -1
  87. package/src/core/utils/datetime-parser.js +1 -1
  88. package/src/core/utils/db.js +1 -1
  89. package/src/core/utils/logger.js +1 -1
  90. package/src/core/utils/payload-loader.js +1 -1
  91. package/src/core/utils/security-checks.js +1 -1
  92. package/src/middleware/body-options.js +1 -1
  93. package/src/middleware/cors.js +1 -1
  94. package/src/middleware/idempotency.js +1 -1
  95. package/src/middleware/rate-limiter.js +1 -1
  96. package/src/middleware/request-logger.js +1 -1
  97. package/src/middleware/security-headers.js +1 -1
  98. package/src/models/base-model-mysql.js +1 -1
  99. package/src/models/base-model-oracle.js +1 -1
  100. package/src/models/base-model-sqlite.js +1 -1
  101. package/src/models/base-model.js +1 -1
  102. package/src/pro/caching/redis-client.js +1 -1
  103. package/src/pro/caching/redis-helper.js +1 -1
  104. package/src/pro/consumers/baseConsumer.js +1 -1
  105. package/src/pro/consumers/declarativeMapper.js +1 -1
  106. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  107. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  108. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  109. package/src/pro/consumers/handlers/index.js +1 -1
  110. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  111. package/src/pro/consumers/index.js +1 -1
  112. package/src/pro/consumers/messageTransformer.js +1 -1
  113. package/src/pro/consumers/validator.js +1 -1
  114. package/src/pro/database/base-model-mysql.js +1 -1
  115. package/src/pro/database/base-model-oracle.js +1 -1
  116. package/src/pro/database/base-model-sqlite.js +1 -1
  117. package/src/pro/database/db-mysql.js +1 -1
  118. package/src/pro/database/db-oracle.js +1 -1
  119. package/src/pro/database/db-sqlite.js +1 -1
  120. package/src/pro/excel/excel-generator.js +1 -1
  121. package/src/pro/excel/excel-parser.js +1 -1
  122. package/src/pro/excel/export-service.js +1 -1
  123. package/src/pro/excel/export_handler.js +1 -1
  124. package/src/pro/excel/import-service.js +1 -1
  125. package/src/pro/excel/import-validator.js +1 -1
  126. package/src/pro/excel/import_handler.js +1 -1
  127. package/src/pro/excel/upsert-builder.js +1 -1
  128. package/src/pro/idgen/idgen-routes.js +1 -1
  129. package/src/pro/integrations/lookup-resolver.js +1 -1
  130. package/src/pro/integrations/upload-handler-v2.js +1 -1
  131. package/src/pro/integrations/upload-handler.js +1 -1
  132. package/src/pro/integrations/webhook.js +1 -1
  133. package/src/pro/locking/lock-routes.js +1 -1
  134. package/src/pro/locking/resource-lock-manager.js +1 -1
  135. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  136. package/src/pro/messaging/kafkaService.js +1 -1
  137. package/src/pro/messaging/messagehubService.js +1 -1
  138. package/src/pro/messaging/rabbitmqService.js +1 -1
  139. package/src/pro/scheduler/job-manager.js +1 -1
  140. package/src/pro/scheduler/job-routes.js +1 -1
  141. package/src/pro/scheduler/job-validator.js +1 -1
  142. package/src/pro/storage/base-storage-provider.js +1 -1
  143. package/src/pro/storage/file-metadata-helper.js +1 -1
  144. package/src/pro/storage/index.js +1 -1
  145. package/src/pro/storage/local-storage-provider.js +1 -1
  146. package/src/pro/storage/s3-storage-provider.js +1 -1
  147. package/src/pro/storage/upload-cleanup-job.js +1 -1
  148. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  149. package/src/pro/storage/upload-pending-tracker.js +1 -1
  150. package/src/pro/websocket/broadcast-helper.js +1 -1
  151. package/src/pro/websocket/index.js +1 -1
  152. package/src/pro/websocket/livesync-server.js +1 -1
  153. package/src/pro/websocket/ws-broadcaster.js +1 -1
  154. package/src/services/export-service.js +1 -1
  155. package/src/services/import-service.js +1 -1
  156. package/src/services/kafkaConsumerService.js +1 -1
  157. package/src/services/kafkaService.js +1 -1
  158. package/src/services/messagehubService.js +1 -1
  159. package/src/services/rabbitmqService.js +1 -1
  160. package/src/utils/cache-invalidation-registry.js +1 -1
  161. package/src/utils/cache-manager.js +1 -1
  162. package/src/utils/component-engine.js +1 -1
  163. package/src/utils/config-extractor.js +1 -1
  164. package/src/utils/consumerLogger.js +1 -1
  165. package/src/utils/context-builder.js +1 -1
  166. package/src/utils/dashboard-helpers.js +1 -1
  167. package/src/utils/dateHelper.js +1 -1
  168. package/src/utils/datetime-formatter.js +1 -1
  169. package/src/utils/datetime-parser.js +1 -1
  170. package/src/utils/db-bootstrap.js +1 -1
  171. package/src/utils/db-mysql.js +1 -1
  172. package/src/utils/db-oracle.js +1 -1
  173. package/src/utils/db-sqlite.js +1 -1
  174. package/src/utils/db.js +1 -1
  175. package/src/utils/demo-generator.js +1 -1
  176. package/src/utils/excel-generator.js +1 -1
  177. package/src/utils/excel-parser.js +1 -1
  178. package/src/utils/file-watcher.js +1 -1
  179. package/src/utils/id-generator.js +1 -1
  180. package/src/utils/idempotency-manager.js +1 -1
  181. package/src/utils/import-validator.js +1 -1
  182. package/src/utils/license-client.js +1 -1
  183. package/src/utils/lock-manager.js +1 -1
  184. package/src/utils/logger.js +1 -1
  185. package/src/utils/lookup-resolver.js +1 -1
  186. package/src/utils/payload-loader.js +1 -1
  187. package/src/utils/processor-response.js +1 -1
  188. package/src/utils/rabbitmq.js +1 -1
  189. package/src/utils/redis-client.js +1 -1
  190. package/src/utils/redis-helper.js +1 -1
  191. package/src/utils/request-scope.js +1 -1
  192. package/src/utils/security-checks.js +1 -1
  193. package/src/utils/service-resolver.js +1 -1
  194. package/src/utils/shutdown-coordinator.js +1 -1
  195. package/src/utils/soft-delete-dashboard-guard.js +1 -0
  196. package/src/utils/sql-table-extractor.js +1 -0
  197. package/src/utils/trusted-keys.js +1 -1
  198. package/src/utils/upload-handler.js +1 -1
  199. package/src/utils/upsert-builder.js +1 -1
  200. package/src/utils/workflow-hook-executor.js +1 -1
  201. 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,579 @@ 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
+ // FK-SD-4 REGISTRY DERIVATION (SDF scan -> RDF) — Fase 1.5 FK-aware soft-delete
779
+ // ============================================================================
780
+ //
781
+ // Menurunkan dua field RDF top-level baru saat `payload generate`, fondasi data untuk
782
+ // FK-SD-1 (forward block), FK-SD-2 (restrict), FK-SD-3 (cascade). Phase ini TIDAK
783
+ // mengubah behavior handler; hanya derivasi + persist metadata.
784
+ //
785
+ // Sumber metadata = SDF scan (Map model `loadSchemaPath`), deterministik dan tanpa
786
+ // query DB (keputusan terkunci Pasca-Discovery). Berbeda dengan jalur restore R20
787
+ // (`deriveSoftDeleteFkChecks`, introspeksi DB) yang TIDAK diubah di sini.
788
+
789
+ /**
790
+ * Normalisasi nilai `onDelete` IR (camelCase) ke semantik FK-SD (keputusan terkunci Q5):
791
+ * - 'cascade' -> 'cascade'
792
+ * - 'setNull' -> 'setNull' (pass-through; ditolak validator Phase 05, bukan di sini)
793
+ * - 'restrict' / 'noAction' / absent -> 'restrict' (default blok)
794
+ *
795
+ * Nilai di luar whitelist (`cascade`/`restrict`/`setNull`/`noAction`) sudah ditolak
796
+ * schema-validator (validateRelations), sehingga input ke fungsi ini dijamin salah satu
797
+ * dari empat itu atau absent (undefined/null). Fallback konservatif ke 'restrict' (blok).
798
+ *
799
+ * @param {string|undefined|null} onDelete - nilai onDelete dari IR relasi
800
+ * @returns {'cascade'|'restrict'|'setNull'}
801
+ */
802
+ function normalizeFkOnDelete(onDelete) {
803
+ if (onDelete === 'cascade') return 'cascade';
804
+ if (onDelete === 'setNull') return 'setNull';
805
+ return 'restrict';
806
+ }
807
+
808
+ /**
809
+ * Muat Map model SDF secara graceful untuk SDF scan FK-SD-4. Berbeda dengan
810
+ * `resolveSoftDeleteForTable` (yang ERROR bila schema-path absen, R12), derivasi registry
811
+ * FK TIDAK boleh menggagalkan generate. Tiga kondisi -> kembalikan null (derivasi kosong):
812
+ * - `--schema-path` absen/kosong (tabel non-soft-delete tak wajib schema-path)
813
+ * - `loadSchemaPath` gagal (folder/SDF invalid)
814
+ * - mode single-file: tetap memuat 1 model, tetapi scan lintas-tabel natural kosong
815
+ * (parent/anak tabel lain tidak ada di Map) -> field dihilangkan secara graceful.
816
+ *
817
+ * @param {string|null|undefined} schemaPath - nilai flag --schema-path
818
+ * @returns {Map<string, Object>|null} Map model SDF, atau null bila tak tersedia
819
+ */
820
+ function loadSchemaMapGraceful(schemaPath) {
821
+ if (typeof schemaPath !== 'string' || schemaPath.trim() === '') return null;
822
+ try {
823
+ return loadSchemaPath(schemaPath);
824
+ } catch (err) {
825
+ return null;
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Resolusi IR model parent yang dirujuk sebuah relasi `belongsTo`. `rel.target` konvensinya
831
+ * adalah nama tabel target: `schema introspect` menamai relasi = nama tabel target, dan bila
832
+ * `target` eksplisit absen IR fallback ke nama relasi (`ir-builder.js:198`), sehingga relasi
833
+ * introspeksi tetap menunjuk tabel yang benar. `findModelByTable` menerima nama bare maupun
834
+ * qualified. Konservatif: target tak cocok model mana pun -> null; bare-name ambigu (>1
835
+ * schema, findModelByTable melempar) -> null.
836
+ *
837
+ * @param {Map<string, Object>} models - Map model SDF
838
+ * @param {Object} rel - relasi IR (harus belongsTo)
839
+ * @returns {Object|null} IR model parent, atau null
840
+ */
841
+ function resolveBelongsToParent(models, rel) {
842
+ if (!rel || rel.type !== 'belongsTo') return null;
843
+ const target = rel.target;
844
+ if (typeof target !== 'string' || target === '') return null;
845
+ try {
846
+ return findModelByTable(models, target);
847
+ } catch (err) {
848
+ return null;
849
+ }
850
+ }
851
+
852
+ // Komparator determinisme snapshot (independen urutan deklarasi/urutan file loader).
853
+ function compareFkParent(a, b) {
854
+ const ka = `${a.refTable}|${a.columns.join(',')}`;
855
+ const kb = `${b.refTable}|${b.columns.join(',')}`;
856
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
857
+ }
858
+ function compareFkChild(a, b) {
859
+ const ka = `${a.childTable}|${a.childColumns.join(',')}`;
860
+ const kb = `${b.childTable}|${b.childColumns.join(',')}`;
861
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
862
+ }
863
+
864
+ /**
865
+ * FK-SD-4 FORWARD registry (`softDeleteFkParents`). Untuk tabel `tableName`, daftar FK
866
+ * outbound (relasi `belongsTo`) yang parent-nya soft-delete-enabled (SDF `softDelete.enabled
867
+ * === true`). Dikonsumsi FK-SD-1 (forward block create/update anak ke parent terhapus).
868
+ *
869
+ * Berlaku untuk SEMUA tabel, TERMASUK tabel anak yang bukan soft-delete (mis. `visitors`):
870
+ * hanya FK ke parent soft-delete yang masuk. Shape per-entry sengaja subset
871
+ * `softDeleteFkChecks` (tanpa `parentSoftDelete`, karena di sini setiap entry pasti
872
+ * soft-delete) agar emit FK-SD-1 dapat memakai ulang pola SQL parent-aktif restore (R20):
873
+ * { columns: [localKey], refTable: <qualified>, refColumns: [references] }
874
+ *
875
+ * `localKey`/`references` dijamin string tunggal oleh validator (validateRelations
876
+ * mewajibkan keduanya string), sehingga selalu single-column array. Graceful empty bila
877
+ * Map/IR/target tidak tersedia (single-file mode, schema-path absen).
878
+ *
879
+ * @param {Map<string, Object>|null} models - Map model SDF (null -> [])
880
+ * @param {string} tableName - nama tabel (bare atau schema.table)
881
+ * @returns {Array<{columns:string[], refTable:string, refColumns:string[]}>}
882
+ */
883
+ function deriveSoftDeleteFkParents(models, tableName) {
884
+ if (!models) return [];
885
+ let ir;
886
+ try {
887
+ ir = findModelByTable(models, tableName);
888
+ } catch (err) {
889
+ return [];
890
+ }
891
+ if (!ir || !ir.relations) return [];
892
+
893
+ const parents = [];
894
+ for (const rel of Object.values(ir.relations)) {
895
+ if (!rel || rel.type !== 'belongsTo') continue;
896
+ if (typeof rel.localKey !== 'string' || typeof rel.references !== 'string') continue;
897
+ const parentModel = resolveBelongsToParent(models, rel);
898
+ if (!parentModel || !isSoftDeleteEnabled(parentModel)) continue;
899
+ parents.push({
900
+ columns: [rel.localKey],
901
+ refTable: parentModel.qualifiedName || parentModel.tableName,
902
+ refColumns: [rel.references]
903
+ });
904
+ }
905
+ parents.sort(compareFkParent);
906
+ return parents;
907
+ }
908
+
909
+ /**
910
+ * FK-SD-4 REVERSE registry (`softDeleteFkChildren`). Untuk tabel master `tableName`, pindai
911
+ * SELURUH model lain di Map: tiap relasi `belongsTo` yang target-nya = master ini berarti
912
+ * tabel pemilik relasi adalah anak. Rekam tabel anak, kolom FK lokal anak, `onDelete`
913
+ * (dinormalisasi Q5), dan status soft-delete anak. Dikonsumsi FK-SD-2 (restrict) & FK-SD-3
914
+ * (cascade).
915
+ *
916
+ * Hanya relasi `belongsTo` yang diperhitungkan: FK fisik selalu berada di tabel anak yang
917
+ * mendeklarasikannya sebagai `belongsTo` (introspeksi DB menghasilkan FK hanya pada child).
918
+ * `hasMany`/`hasOne` adalah view logis inverse dari FK yang sama (kolom-nya bukan FK fisik
919
+ * di tabel pendeklarasi), sehingga TIDAK dipakai agar tidak salah-arah/duplikat. Pencocokan
920
+ * master dilakukan dengan me-resolve target relasi ke model lalu membandingkan qualifiedName
921
+ * (robust terhadap target bare vs qualified). Self-reference (anak == master) sah dan
922
+ * disertakan. Pemanggil HARUS hanya memanggil ini untuk tabel soft-delete (guard
923
+ * hasSoftDeleteColumns); fungsi sendiri tidak menambah guard itu.
924
+ *
925
+ * `parentColumn` (= `rel.references`) membawa kolom MASTER yang dirujuk FK anak. FK-SD-2/3
926
+ * mem-query anak dengan `WHERE <childColumn> = <nilai parentColumn master row>`; mengandalkan
927
+ * PK master salah bila FK merujuk kolom non-PK. Diambil dari relasi, BUKAN diasumsikan = PK.
928
+ * Sejalan forward derivation, `localKey`/`references` wajib string tunggal (composite ditolak
929
+ * validator); entry tanpa keduanya string di-skip (defensif, konsisten determinisme snapshot).
930
+ *
931
+ * @param {Map<string, Object>|null} models - Map model SDF (null -> [])
932
+ * @param {string} tableName - nama tabel master (bare atau schema.table)
933
+ * @returns {Array<{childTable:string, childColumns:string[], parentColumn:string, onDelete:string, childSoftDelete:boolean}>}
934
+ */
935
+ function deriveSoftDeleteFkChildren(models, tableName) {
936
+ if (!models) return [];
937
+ let masterIr;
938
+ try {
939
+ masterIr = findModelByTable(models, tableName);
940
+ } catch (err) {
941
+ return [];
942
+ }
943
+ if (!masterIr) return [];
944
+ const masterQualified = masterIr.qualifiedName || masterIr.tableName;
945
+
946
+ const children = [];
947
+ for (const childModel of models.values()) {
948
+ if (!childModel || !childModel.relations) continue;
949
+ for (const rel of Object.values(childModel.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) continue;
954
+ if ((parentModel.qualifiedName || parentModel.tableName) !== masterQualified) continue;
955
+ children.push({
956
+ childTable: childModel.qualifiedName || childModel.tableName,
957
+ childColumns: [rel.localKey],
958
+ parentColumn: rel.references,
959
+ onDelete: normalizeFkOnDelete(rel.onDelete),
960
+ childSoftDelete: isSoftDeleteEnabled(childModel)
961
+ });
962
+ }
963
+ }
964
+ children.sort(compareFkChild);
965
+ return children;
966
+ }
967
+
968
+ /**
969
+ * FK-SD-3 (Fase 1.5) — derivasi closure cascade transitif untuk master soft-delete.
970
+ * Mengadopsi algoritma spike Phase 04s (`buildCascadeClosure`, terbukti deterministik +
971
+ * cycle-safe) menjadi fungsi produksi. Dari `masterTable`, hitung peta node (master +
972
+ * SELURUH keturunan yang reachable lewat edge `onDelete:cascade` ke anak soft-delete),
973
+ * masing-masing membawa metadata yang dibutuhkan walk runtime (Phase 04b) untuk
974
+ * men-soft-delete dirinya + menjalar lebih dalam. Closure dipersist PENUH di RDF master
975
+ * (keputusan orchestrator) agar template Phase 04b tetap Map-free: tidak ada lookup
976
+ * lintas-RDF/akses DB saat codegen.
977
+ *
978
+ * Tiap node menyimpan:
979
+ * - table : nama qualified node
980
+ * - pk : primary key node (string bila single-column, array bila composite)
981
+ * dari IR `primaryKey`
982
+ * - reusable : [{field, length}] dari IR `softDelete.reusable` node ([] bila tidak ada)
983
+ * - children : SELURUH anak langsung (cascade + restrict + setNull), tiap entry
984
+ * { childTable, childColumn, parentColumn, onDelete, childSoftDelete }.
985
+ * Anak restrict/setNull DIREKAM (agar restrict-per-node Phase 04c bisa
986
+ * cek di SETIAP node, bukan hanya master) tetapi TIDAK memicu rekursi.
987
+ *
988
+ * Aturan expand (rekursi): HANYA ke `childTable` dari edge `onDelete==='cascade'` yang
989
+ * `childSoftDelete===true`. Edge cascade ke anak NON-soft-delete direkam di `children`
990
+ * (`childSoftDelete:false`) tetapi TIDAK di-expand dan TIDAK error di sini (konfigurasi
991
+ * itu ditolak validator Phase 05). Edge restrict/setNull direkam tetapi tidak di-expand.
992
+ *
993
+ * Cycle-safe (level closure): `visited` set berisi qualified table name; tabel yang sudah
994
+ * masuk `nodes` tidak di-expand ulang, sehingga closure pasti terminasi pada graf
995
+ * ber-cycle (self-reference, A→B→A) maupun diamond (anak dengan ≥2 parent cascade diproses
996
+ * sekali). `parentColumn = rel.references` (BUKAN diasumsikan PK), konsisten Phase 03.
997
+ *
998
+ * `order` = urutan kunjungan node DFS pre-order dengan anak cascade dikunjungi menurut
999
+ * urutan sorted `deriveSoftDeleteFkChildren` (master lebih dulu, lalu descendant). Stabil
1000
+ * dan deterministik untuk snapshot RDF.
1001
+ *
1002
+ * Graceful: Map null/parsial (single-file, `--schema-path` absen) → `deriveSoftDeleteFkChildren`
1003
+ * mengembalikan [] sehingga tree berisi hanya node master tanpa children, konsisten Phase 01.
1004
+ * (Pemanggil men-guard persist dengan ≥1 anak cascade, sehingga kasus graceful itu tidak
1005
+ * mempersist field sama sekali.)
1006
+ *
1007
+ * @param {Map<string, Object>|null} models - Map model SDF
1008
+ * @param {string} masterTable - nama tabel master (bare atau schema.table)
1009
+ * @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}>}> }}
1010
+ */
1011
+ function buildSoftDeleteCascadeTree(models, masterTable) {
1012
+ let masterIr = null;
1013
+ if (models) {
1014
+ try {
1015
+ masterIr = findModelByTable(models, masterTable);
1016
+ } catch (err) {
1017
+ masterIr = null;
1018
+ }
1019
+ }
1020
+ // Map tak tersedia / master tak ada di Map (single-file, schema-path absen): pakai nama
1021
+ // tabel apa adanya. directChildren akan kosong (deriveSoftDeleteFkChildren graceful),
1022
+ // menghasilkan tree minimal (hanya master, tanpa expand). Konsisten Phase 01.
1023
+ const masterQ = masterIr
1024
+ ? (masterIr.qualifiedName || masterIr.tableName)
1025
+ : masterTable;
1026
+
1027
+ const nodes = {};
1028
+ const order = [];
1029
+ const visited = new Set();
1030
+ const stack = [masterQ];
1031
+
1032
+ while (stack.length) {
1033
+ const tableQ = stack.pop();
1034
+ if (visited.has(tableQ)) continue; // cycle/diamond guard: tabel sudah diproses
1035
+ visited.add(tableQ);
1036
+
1037
+ const ir = models ? models.get(tableQ) : null;
1038
+ const pkArr = (ir && Array.isArray(ir.primaryKey)) ? ir.primaryKey : [];
1039
+ const reusable = (ir && ir.softDelete && Array.isArray(ir.softDelete.reusable))
1040
+ ? ir.softDelete.reusable.map((r) => ({ field: r.field, length: r.length }))
1041
+ : [];
1042
+
1043
+ // Anak langsung — derivasi REVERSE registry ASLI (Phase 01/03), sudah membawa
1044
+ // childColumns/parentColumn/onDelete/childSoftDelete dan tersortir deterministik.
1045
+ const directChildren = deriveSoftDeleteFkChildren(models, tableQ);
1046
+
1047
+ nodes[tableQ] = {
1048
+ table: tableQ,
1049
+ pk: pkArr.length === 1 ? pkArr[0] : pkArr.slice(),
1050
+ reusable,
1051
+ children: directChildren.map((c) => ({
1052
+ childTable: c.childTable,
1053
+ childColumn: c.childColumns[0],
1054
+ parentColumn: c.parentColumn,
1055
+ onDelete: c.onDelete,
1056
+ childSoftDelete: c.childSoftDelete
1057
+ }))
1058
+ };
1059
+ order.push(tableQ);
1060
+
1061
+ // Expand HANYA edge cascade ke anak soft-delete-enabled. Push REVERSE agar pop dalam
1062
+ // urutan sorted (DFS pre-order stabil). Cascade→non-SD & restrict/setNull TIDAK di-expand.
1063
+ const toExpand = [];
1064
+ for (const c of directChildren) {
1065
+ if (c.onDelete !== 'cascade' || !c.childSoftDelete) continue;
1066
+ if (visited.has(c.childTable)) continue;
1067
+ toExpand.push(c.childTable);
1068
+ }
1069
+ for (let i = toExpand.length - 1; i >= 0; i--) stack.push(toExpand[i]);
1070
+ }
1071
+
1072
+ return { master: masterQ, order, nodes };
1073
+ }
1074
+
490
1075
  // ============================================================================
491
1076
  // ADVISORIES (informational) — untuk payload validate
492
1077
  // ============================================================================
@@ -1115,6 +1700,13 @@ class PayloadGenerator {
1115
1700
  console.log(`Table: ${payloadData.tableName}`);
1116
1701
  console.log(`Primary Key: ${payloadData.primaryKey}`);
1117
1702
 
1703
+ // Flag kehadiran kolom soft-delete di DB (R12). DITANGKAP dari daftar kolom DB
1704
+ // SEBELUM strip fieldName, supaya resolveSoftDeleteForTable/derivation (~1786)
1705
+ // tetap jalan dan tetap ERROR bila kolom hadir di DB tetapi SDF tidak punya blok
1706
+ // softDelete valid. Strip middle-ground beroperasi pada PROYEKSI fieldName, bukan
1707
+ // pada daftar kolom DB yang dipakai cek konsistensi.
1708
+ let hasSoftDeleteColumns = false;
1709
+
1118
1710
  // Get fields from database
1119
1711
  if (this.db.pool) {
1120
1712
  const columns = await this.db.getColumns(args.table);
@@ -1130,6 +1722,22 @@ class PayloadGenerator {
1130
1722
  payloadData.auditColumns = false;
1131
1723
  console.log('No audit columns detected in table: setting auditColumns: false');
1132
1724
  }
1725
+
1726
+ // Soft-delete middle-ground (audit-parity): keluarkan tiga kolom soft-delete
1727
+ // (is_deleted/deleted_at/deleted_by) dari PROYEKSI fieldName agar RDF bersih.
1728
+ // Seluruh turunan (datatablesQuery SQL, datatablesWhere, dateTimeFields,
1729
+ // fieldValidation) diturunkan dari fieldName SETELAH titik ini sehingga ikut
1730
+ // bersih otomatis. validFields MODEL tetap memuat tiga kolom (di-append di
1731
+ // template) supaya mekanisme visibility R15 tetap lolos guard BaseModel.
1732
+ // Flag ditangkap dari kolom DB (bukan fieldName yang sudah di-strip).
1733
+ hasSoftDeleteColumns = SOFT_DELETE_COLUMNS.some(col => columns.includes(col));
1734
+ if (hasSoftDeleteColumns) {
1735
+ const softDeleteStripped = payloadData.fieldName.filter(col => SOFT_DELETE_COLUMNS.includes(col));
1736
+ payloadData.fieldName = payloadData.fieldName.filter(col => !SOFT_DELETE_COLUMNS.includes(col));
1737
+ if (softDeleteStripped.length > 0) {
1738
+ console.log(`Soft-delete columns excluded from RDF fieldName: ${softDeleteStripped.join(', ')}`);
1739
+ }
1740
+ }
1133
1741
  }
1134
1742
  }
1135
1743
 
@@ -1222,6 +1830,78 @@ class PayloadGenerator {
1222
1830
  .map((c) => ({ name: c.name, fields: c.columns }));
1223
1831
  }
1224
1832
 
1833
+ // Soft-delete derivation (R12/R13): bila tabel memiliki satu atau lebih kolom
1834
+ // soft-delete (is_deleted/deleted_at/deleted_by), wajib resolusi SDF dengan blok
1835
+ // softDelete valid lalu turunkan blok ke RDF (+ override maxLength reusable = base).
1836
+ // Guard ketat: tabel non-soft-delete tidak menyentuh jalur ini sama sekali, sehingga
1837
+ // output byte-identik baseline (--schema-path diabaikan untuk tabel non-soft-delete).
1838
+ // CATATAN: `hasSoftDeleteColumns` di-capture dari daftar kolom DB SEBELUM strip
1839
+ // fieldName (lihat ~1662), karena fieldName kini sudah tidak memuat tiga kolom.
1840
+
1841
+ // FK-SD-4 (Fase 1.5): muat Map SDF SEKALI secara graceful untuk derivasi registry FK
1842
+ // (forward `softDeleteFkParents` + reverse `softDeleteFkChildren`). Berbeda dengan
1843
+ // resolveSoftDeleteForTable yang ERROR bila schema-path absen (R12), registry FK tidak
1844
+ // boleh menggagalkan generate: Map null -> derivasi kosong (field dihilangkan secara
1845
+ // graceful). Tidak menambah query DB saat runtime (derivasi hanya saat generate).
1846
+ const fkRegistryModels = loadSchemaMapGraceful(args['schema-path']);
1847
+
1848
+ if (hasSoftDeleteColumns) {
1849
+ const sdfSoftDelete = resolveSoftDeleteForTable(payloadData.tableName, args['schema-path']);
1850
+ applySoftDeleteDerivation(payloadData, sdfSoftDelete);
1851
+ const reusableCount = Array.isArray(sdfSoftDelete.reusable)
1852
+ ? sdfSoftDelete.reusable.length
1853
+ : 0;
1854
+
1855
+ // FK-awareness (R20): derive daftar cek FK + status parent-soft-delete dari introspeksi
1856
+ // DB. Top-level (pola uniqueConstraints), guarded oleh hasSoftDeleteColumns → tabel
1857
+ // non-soft-delete tidak menyentuh field ini (diff-zero baseline terjaga). Dikonsumsi
1858
+ // template restore (R20) saat action.restore aktif. TIDAK diubah Fase 1.5.
1859
+ payloadData.softDeleteFkChecks = await deriveSoftDeleteFkChecks(this.db, payloadData.tableName);
1860
+ const softDeleteParentCount = payloadData.softDeleteFkChecks.filter((c) => c.parentSoftDelete).length;
1861
+
1862
+ // FK-SD-4 REVERSE registry (Fase 1.5): anak yang menunjuk master ini, untuk FK-SD-2
1863
+ // (restrict) & FK-SD-3 (cascade). Persist SELALU untuk tabel soft-delete (boleh []),
1864
+ // mengikuti preseden softDeleteFkChecks. Diturunkan via SDF scan (Map), bukan DB.
1865
+ payloadData.softDeleteFkChildren = deriveSoftDeleteFkChildren(fkRegistryModels, payloadData.tableName);
1866
+
1867
+ // FK-SD-3 (Fase 1.5, Phase 04a/04b): closure cascade transitif (master + seluruh keturunan
1868
+ // reachable lewat edge cascade ke anak soft-delete). Persist PENUH di RDF master agar
1869
+ // walk Phase 04b Map-free. Guard: HANYA bila master punya ≥1 anak langsung
1870
+ // onDelete:cascade YANG anaknya soft-delete-enabled (childSoftDelete === true) — sebab
1871
+ // softDeleteCascadeTree hanya bermakna bila ada cascade NYATA yang bisa di-walk. Edge
1872
+ // cascade→non-soft-delete adalah config invalid (ditolak validator Phase 05) → tidak
1873
+ // mempersist tree degenerate. Master tanpa cascade expandable → field DIHILANGKAN
1874
+ // (diff-zero baseline + komposisi Phase 03 restrict-only tetap utuh). Penempatan tepat
1875
+ // setelah softDeleteFkChildren → urutan key RDF deterministik.
1876
+ const hasCascadeChild = payloadData.softDeleteFkChildren.some(
1877
+ (c) => c.onDelete === 'cascade' && c.childSoftDelete === true
1878
+ );
1879
+ let cascadeNodeCount = 0;
1880
+ if (hasCascadeChild) {
1881
+ payloadData.softDeleteCascadeTree =
1882
+ buildSoftDeleteCascadeTree(fkRegistryModels, payloadData.tableName);
1883
+ cascadeNodeCount = payloadData.softDeleteCascadeTree.order.length;
1884
+ }
1885
+
1886
+ console.log(
1887
+ `Soft-delete detected: derived softDelete block from SDF ` +
1888
+ `('${args['schema-path']}', ${reusableCount} reusable field(s); ` +
1889
+ `${payloadData.softDeleteFkChecks.length} FK check(s), ${softDeleteParentCount} soft-delete parent(s); ` +
1890
+ `${payloadData.softDeleteFkChildren.length} reverse child(ren)` +
1891
+ (hasCascadeChild ? `; cascade closure ${cascadeNodeCount} node(s)` : '') + `).`
1892
+ );
1893
+ }
1894
+
1895
+ // FK-SD-4 FORWARD registry (Fase 1.5): FK outbound tabel ini ke parent soft-delete, untuk
1896
+ // FK-SD-1 (forward block create/update). Berlaku untuk SEMUA tabel TERMASUK anak
1897
+ // non-soft-delete (mis. visitors), sehingga jalur ini di LUAR guard hasSoftDeleteColumns.
1898
+ // Persist HANYA bila array non-kosong → tabel netral (tanpa softDelete & tanpa FK ke
1899
+ // tabel soft-delete) tetap byte-identik baseline (diff-zero terjaga).
1900
+ const softDeleteFkParents = deriveSoftDeleteFkParents(fkRegistryModels, payloadData.tableName);
1901
+ if (softDeleteFkParents.length > 0) {
1902
+ payloadData.softDeleteFkParents = softDeleteFkParents;
1903
+ }
1904
+
1225
1905
  // Save payload
1226
1906
  this.ensureOutputDir();
1227
1907
  const filename = baseFilename + '.json';
@@ -1239,6 +1919,15 @@ class PayloadGenerator {
1239
1919
  console.log();
1240
1920
  console.log(`Payload saved: ${outputPath}`);
1241
1921
  console.log(`Query saved: ${sqlOutputPath}`);
1922
+
1923
+ // Checkpoint 2 soft-delete dashboard guard (WARNING, non-blocking): bila RDF yang baru
1924
+ // ditulis adalah tabel soft-delete, scan dashboard yang sudah ada di outputDir terhadap
1925
+ // registry soft-delete saat ini lalu peringatkan dashboard yang bocor. Gating
1926
+ // isSoftDeleteEnabled memastikan tabel netral tidak memicu scan (no noise, no regresi).
1927
+ // Tidak boleh menggagalkan generate (fungsi warn bersifat best-effort, tidak throw).
1928
+ if (isSoftDeleteEnabled(payloadData)) {
1929
+ warnSoftDeleteDashboardDrift(this.outputDir);
1930
+ }
1242
1931
  }
1243
1932
  }
1244
1933
 
@@ -1863,6 +2552,14 @@ class SchemaValidator {
1863
2552
  enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
1864
2553
  const stringColumns = collectStringColumns(enrichedColumns);
1865
2554
 
2555
+ // Soft-delete middle-ground (audit-parity): bila blok softDelete dipertahankan di
2556
+ // RDF (lewat spread ...oldPayload), keluarkan tiga kolom soft-delete dari fieldName
2557
+ // hasil sync sama seperti jalur generate. Gate dari oldPayload (blok softDelete tidak
2558
+ // berubah saat sync). Tabel non-soft-delete: gate false → newFieldName byte-identik
2559
+ // baseline. Strip di KEDUA loop agar RDF lama (yang masih memuat tiga kolom di
2560
+ // fieldName) ikut dibersihkan secara idempoten saat sync.
2561
+ const stripSoftDelete = isSoftDeleteEnabled(oldPayload);
2562
+
1866
2563
  // Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
1867
2564
  // Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
1868
2565
  // dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
@@ -1870,6 +2567,7 @@ class SchemaValidator {
1870
2567
  const newFieldName = [];
1871
2568
  // Pertahankan field lama yang masih ada di database
1872
2569
  for (const field of oldPayload.fieldName) {
2570
+ if (stripSoftDelete && SOFT_DELETE_COLUMNS.includes(field)) continue;
1873
2571
  if (dbColumns.includes(field)) {
1874
2572
  newFieldName.push(field);
1875
2573
  }
@@ -1878,6 +2576,7 @@ class SchemaValidator {
1878
2576
  for (const col of dbColumns) {
1879
2577
  if (newFieldName.includes(col)) continue;
1880
2578
  if (DEFAULT_AUDIT_COLUMNS.includes(col)) continue;
2579
+ if (stripSoftDelete && SOFT_DELETE_COLUMNS.includes(col)) continue;
1881
2580
  newFieldName.push(col);
1882
2581
  }
1883
2582
 
@@ -2056,5 +2755,16 @@ module.exports = {
2056
2755
  collectStringColumns,
2057
2756
  isDefaultSearchableColumn,
2058
2757
  applyIsActiveDefaultScope,
2059
- buildPayloadAdvisories
2758
+ applySoftDeleteDerivation,
2759
+ resolveSoftDeleteForTable,
2760
+ deriveSoftDeleteFkChecks,
2761
+ isParentSoftDelete,
2762
+ findModelByTable,
2763
+ loadSchemaMapGraceful,
2764
+ normalizeFkOnDelete,
2765
+ deriveSoftDeleteFkParents,
2766
+ deriveSoftDeleteFkChildren,
2767
+ buildSoftDeleteCascadeTree,
2768
+ buildPayloadAdvisories,
2769
+ warnSoftDeleteDashboardDrift
2060
2770
  };