@restforgejs/platform 5.1.0 → 5.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/build-info.json +2 -2
  2. package/cli/consumer-deploy.js +1 -1
  3. package/cli/consumer.js +1 -1
  4. package/generators/cli/data/pull.js +95 -0
  5. package/generators/cli/data/push.js +85 -0
  6. package/generators/cli/fast-track.js +12 -25
  7. package/generators/cli/schema/introspect.js +10 -10
  8. package/generators/lib/data/db-executor.js +440 -0
  9. package/generators/lib/data/dialect-kit.js +56 -0
  10. package/generators/lib/data/envelope.js +220 -0
  11. package/generators/lib/data/pull-runner.js +407 -0
  12. package/generators/lib/data/push-runner.js +382 -0
  13. package/generators/lib/data/sdf-reader.js +132 -0
  14. package/generators/lib/data/table-order.js +126 -0
  15. package/generators/lib/data/value-codec.js +188 -0
  16. package/generators/lib/templates/dashboard-catalog.js +1 -1
  17. package/generators/lib/templates/db-connection-env.js +1 -1
  18. package/generators/lib/templates/dbschema-catalog.js +1 -1
  19. package/generators/lib/templates/field-validation-catalog.js +1 -1
  20. package/generators/lib/templates/mysql-template.js +1 -1
  21. package/generators/lib/templates/oracle-template.js +1 -1
  22. package/generators/lib/templates/postgres-template.js +1 -1
  23. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  24. package/generators/lib/templates/sqlite-template.js +1 -1
  25. package/integrity-manifest.json +18 -18
  26. package/package.json +1 -1
  27. package/scripts/verify-integrity.js +1 -1
  28. package/server.js +1 -1
  29. package/src/components/handlers/adjust_handler.js +1 -1
  30. package/src/components/handlers/audit_handler.js +1 -1
  31. package/src/components/handlers/delete_handler.js +1 -1
  32. package/src/components/handlers/export_handler.js +1 -1
  33. package/src/components/handlers/import_handler.js +1 -1
  34. package/src/components/handlers/insert_handler.js +1 -1
  35. package/src/components/handlers/update_handler.js +1 -1
  36. package/src/components/handlers/upload_handler.js +1 -1
  37. package/src/components/handlers/workflow_handler.js +1 -1
  38. package/src/components/integrations/webhook.js +1 -1
  39. package/src/consumers/baseConsumer.js +1 -1
  40. package/src/consumers/declarativeMapper.js +1 -1
  41. package/src/consumers/handlers/apiHandler.js +1 -1
  42. package/src/consumers/handlers/consoleHandler.js +1 -1
  43. package/src/consumers/handlers/databaseHandler.js +1 -1
  44. package/src/consumers/handlers/index.js +1 -1
  45. package/src/consumers/handlers/kafkaHandler.js +1 -1
  46. package/src/consumers/index.js +1 -1
  47. package/src/consumers/messageTransformer.js +1 -1
  48. package/src/consumers/validator.js +1 -1
  49. package/src/core/db/dialect/base-dialect.js +1 -1
  50. package/src/core/db/dialect/index.js +1 -1
  51. package/src/core/db/dialect/mysql-dialect.js +1 -1
  52. package/src/core/db/dialect/oracle-dialect.js +1 -1
  53. package/src/core/db/dialect/postgres-dialect.js +1 -1
  54. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  55. package/src/core/db/flatten-helper.js +1 -1
  56. package/src/core/db/query-builder-error.js +1 -1
  57. package/src/core/db/query-builder.js +1 -1
  58. package/src/core/db/relation-helper.js +1 -1
  59. package/src/core/handlers/delete_handler.js +1 -1
  60. package/src/core/handlers/insert_handler.js +1 -1
  61. package/src/core/handlers/update_handler.js +1 -1
  62. package/src/core/models/base-model.js +1 -1
  63. package/src/core/utils/cache-manager.js +1 -1
  64. package/src/core/utils/component-engine.js +1 -1
  65. package/src/core/utils/context-builder.js +1 -1
  66. package/src/core/utils/datetime-formatter.js +1 -1
  67. package/src/core/utils/datetime-parser.js +1 -1
  68. package/src/core/utils/db.js +1 -1
  69. package/src/core/utils/logger.js +1 -1
  70. package/src/core/utils/payload-loader.js +1 -1
  71. package/src/core/utils/security-checks.js +1 -1
  72. package/src/middleware/body-options.js +1 -1
  73. package/src/middleware/cors.js +1 -1
  74. package/src/middleware/idempotency.js +1 -1
  75. package/src/middleware/rate-limiter.js +1 -1
  76. package/src/middleware/request-logger.js +1 -1
  77. package/src/middleware/security-headers.js +1 -1
  78. package/src/models/base-model-mysql.js +1 -1
  79. package/src/models/base-model-oracle.js +1 -1
  80. package/src/models/base-model-sqlite.js +1 -1
  81. package/src/models/base-model.js +1 -1
  82. package/src/pro/caching/redis-client.js +1 -1
  83. package/src/pro/caching/redis-helper.js +1 -1
  84. package/src/pro/consumers/baseConsumer.js +1 -1
  85. package/src/pro/consumers/declarativeMapper.js +1 -1
  86. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  87. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  88. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  89. package/src/pro/consumers/handlers/index.js +1 -1
  90. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  91. package/src/pro/consumers/index.js +1 -1
  92. package/src/pro/consumers/messageTransformer.js +1 -1
  93. package/src/pro/consumers/validator.js +1 -1
  94. package/src/pro/database/base-model-mysql.js +1 -1
  95. package/src/pro/database/base-model-oracle.js +1 -1
  96. package/src/pro/database/base-model-sqlite.js +1 -1
  97. package/src/pro/database/db-mysql.js +1 -1
  98. package/src/pro/database/db-oracle.js +1 -1
  99. package/src/pro/database/db-sqlite.js +1 -1
  100. package/src/pro/excel/excel-generator.js +1 -1
  101. package/src/pro/excel/excel-parser.js +1 -1
  102. package/src/pro/excel/export-service.js +1 -1
  103. package/src/pro/excel/export_handler.js +1 -1
  104. package/src/pro/excel/import-service.js +1 -1
  105. package/src/pro/excel/import-validator.js +1 -1
  106. package/src/pro/excel/import_handler.js +1 -1
  107. package/src/pro/excel/upsert-builder.js +1 -1
  108. package/src/pro/idgen/idgen-routes.js +1 -1
  109. package/src/pro/integrations/lookup-resolver.js +1 -1
  110. package/src/pro/integrations/upload-handler-v2.js +1 -1
  111. package/src/pro/integrations/upload-handler.js +1 -1
  112. package/src/pro/integrations/webhook.js +1 -1
  113. package/src/pro/locking/lock-routes.js +1 -1
  114. package/src/pro/locking/resource-lock-manager.js +1 -1
  115. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  116. package/src/pro/messaging/kafkaService.js +1 -1
  117. package/src/pro/messaging/messagehubService.js +1 -1
  118. package/src/pro/messaging/rabbitmqService.js +1 -1
  119. package/src/pro/scheduler/job-manager.js +1 -1
  120. package/src/pro/scheduler/job-routes.js +1 -1
  121. package/src/pro/scheduler/job-validator.js +1 -1
  122. package/src/pro/storage/base-storage-provider.js +1 -1
  123. package/src/pro/storage/file-metadata-helper.js +1 -1
  124. package/src/pro/storage/index.js +1 -1
  125. package/src/pro/storage/local-storage-provider.js +1 -1
  126. package/src/pro/storage/s3-storage-provider.js +1 -1
  127. package/src/pro/storage/upload-cleanup-job.js +1 -1
  128. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  129. package/src/pro/storage/upload-pending-tracker.js +1 -1
  130. package/src/pro/websocket/broadcast-helper.js +1 -1
  131. package/src/pro/websocket/index.js +1 -1
  132. package/src/pro/websocket/livesync-server.js +1 -1
  133. package/src/pro/websocket/ws-broadcaster.js +1 -1
  134. package/src/services/export-service.js +1 -1
  135. package/src/services/import-service.js +1 -1
  136. package/src/services/kafkaConsumerService.js +1 -1
  137. package/src/services/kafkaService.js +1 -1
  138. package/src/services/messagehubService.js +1 -1
  139. package/src/services/rabbitmqService.js +1 -1
  140. package/src/utils/cache-invalidation-registry.js +1 -1
  141. package/src/utils/cache-manager.js +1 -1
  142. package/src/utils/component-engine.js +1 -1
  143. package/src/utils/config-extractor.js +1 -1
  144. package/src/utils/consumerLogger.js +1 -1
  145. package/src/utils/context-builder.js +1 -1
  146. package/src/utils/dashboard-helpers.js +1 -1
  147. package/src/utils/dateHelper.js +1 -1
  148. package/src/utils/datetime-formatter.js +1 -1
  149. package/src/utils/datetime-parser.js +1 -1
  150. package/src/utils/db-bootstrap.js +1 -1
  151. package/src/utils/db-mysql.js +1 -1
  152. package/src/utils/db-oracle.js +1 -1
  153. package/src/utils/db-sqlite.js +1 -1
  154. package/src/utils/db.js +1 -1
  155. package/src/utils/demo-generator.js +1 -1
  156. package/src/utils/excel-generator.js +1 -1
  157. package/src/utils/excel-parser.js +1 -1
  158. package/src/utils/file-watcher.js +1 -1
  159. package/src/utils/id-generator.js +1 -1
  160. package/src/utils/idempotency-manager.js +1 -1
  161. package/src/utils/import-validator.js +1 -1
  162. package/src/utils/license-client.js +1 -1
  163. package/src/utils/lock-manager.js +1 -1
  164. package/src/utils/logger.js +1 -1
  165. package/src/utils/lookup-resolver.js +1 -1
  166. package/src/utils/payload-loader.js +1 -1
  167. package/src/utils/processor-response.js +1 -1
  168. package/src/utils/rabbitmq.js +1 -1
  169. package/src/utils/redis-client.js +1 -1
  170. package/src/utils/redis-helper.js +1 -1
  171. package/src/utils/request-scope.js +1 -1
  172. package/src/utils/security-checks.js +1 -1
  173. package/src/utils/service-resolver.js +1 -1
  174. package/src/utils/shutdown-coordinator.js +1 -1
  175. package/src/utils/trusted-keys.js +1 -1
  176. package/src/utils/upload-handler.js +1 -1
  177. package/src/utils/upsert-builder.js +1 -1
  178. package/src/utils/workflow-hook-executor.js +1 -1
@@ -0,0 +1,382 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * push-runner — Business logic command `data push` (append-only, amandemen A6).
5
+ *
6
+ * Push bersifat append-only: tidak ada pilihan mode. Mode `upsert`/`replace`
7
+ * di-descope dari v1 (User konfirmasi 2026-06-03), sehingga flag `--mode`/`--force`
8
+ * dihapus dari contract dan runner tidak lagi membaca/memvalidasi keduanya.
9
+ *
10
+ * `run()` adalah dispatcher (single vs all-tables); `runOne(ir, ctx)` mengeksekusi
11
+ * push satu tabel dan mengelola executor-nya sendiri (Phase 03, data-pull-push-v2).
12
+ *
13
+ * Alur dispatcher `run()`:
14
+ * 1. Resolve config DB tujuan (config-resolver) → loadConfig (dbschema-kit/connection); SEKALI.
15
+ * 2. Validasi mutual-exclusive (sebelum load SDF / koneksi DB): tepat satu dari
16
+ * --table / --all-tables (keduanya / tidak keduanya → exit 2).
17
+ * 3. Load SDF SEKALI → Map<qualifiedName, IR> (sdf-reader).
18
+ * 4a. Jalur --table: findTableOrThrow → runOne(ir, ctx) → ringkasan single (regresi nol).
19
+ * 4b. Jalur --all-tables: topoSortByFk(models) → urutan parent→child (table-order); siklus →
20
+ * warning stderr + fallback urutan deklarasi. Loop FAIL-FAST (Q3); tabel tanpa file
21
+ * input di-SKIP (warning stderr, catat di skipped[], lanjut) → runOne per IR → agregat.
22
+ *
23
+ * Alur `runOne(ir, ctx)` (per tabel; metadata kolom/namespace/PK murni dari SDF IR):
24
+ * a. Derive nama file dari SDF (aturan IDENTIK pull): {cwd}/{input}/{qualifiedName}.json.
25
+ * File tidak ada → exit 1. Baca + parseEnvelope + validateEnvelope (shape invalid → exit 1).
26
+ * b. Column match vs SDF (BUKAN introspeksi DB): tiap envelope.columns[].name harus ada
27
+ * di SDF dan tipe konsisten. Mismatch → exit 1. No-PK guard (exit 1).
28
+ * c. Connect (db-executor) + validasi identifier tabel/kolom (anti-injection).
29
+ * d. INSERT batch + commit per batch: tiap nilai di-decode (value-codec) per dialect tujuan,
30
+ * INSERT parameterized per baris dirakit via dialect helper (placeholder) dan dijalankan
31
+ * via executeTransaction (commit per batch). Append: konflik PK = error DB (exit 3).
32
+ * e. Disconnect (selalu, di finally).
33
+ *
34
+ * Exit code: 0 sukses; 1 operational (file tidak ada, envelope invalid, column mismatch
35
+ * vs SDF, no-PK anomali); 2 usage/precondition (--config wajib, mutual-exclusive, tabel
36
+ * tak terdaftar/ambigu di SDF); 3 database error (koneksi maupun INSERT, termasuk konflik
37
+ * PK pada append). Handler tidak memanggil process.exit() sendiri.
38
+ *
39
+ * @module generators/lib/data/push-runner
40
+ */
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+
45
+ const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
46
+ const { loadConfig } = require('../dbschema-kit/connection');
47
+ const {
48
+ loadSdf,
49
+ findTableOrThrow,
50
+ irToColumns,
51
+ getNamespace,
52
+ getPrimaryKey
53
+ } = require('./sdf-reader');
54
+ const { createDialect, validateIdentifier, placeholder } = require('./dialect-kit');
55
+ const { createExecutor } = require('./db-executor');
56
+ const { decodeValue } = require('./value-codec');
57
+ const { parseEnvelope, validateEnvelope } = require('./envelope');
58
+ const { topoSortByFk } = require('./table-order');
59
+
60
+ const DEFAULT_BATCH_SIZE = 1000;
61
+
62
+ /**
63
+ * Buat Error dengan exitCode terlampir (override default dispatcher).
64
+ * @param {string} message
65
+ * @param {number} exitCode
66
+ * @returns {Error}
67
+ */
68
+ function failWith(message, exitCode) {
69
+ const err = new Error(message);
70
+ err.exitCode = exitCode;
71
+ return err;
72
+ }
73
+
74
+ /**
75
+ * Petakan `config.dialect` internal ke bentuk DB_TYPE kanonik untuk output.
76
+ *
77
+ * `loadConfig` memetakan `DB_TYPE=postgresql` menjadi `dialect='postgres'`; output
78
+ * push (`target_dialect`) memakai bentuk kanonik `postgresql`, konsisten dengan
79
+ * `source_dialect` pull (pull-runner.canonicalSourceDialect). Dialect lain identik.
80
+ *
81
+ * @param {string} dialect - config.dialect ('postgres'|'mysql'|'oracle'|'sqlite')
82
+ * @returns {string} 'postgresql'|'mysql'|'oracle'|'sqlite'
83
+ */
84
+ function canonicalTargetDialect(dialect) {
85
+ return dialect === 'postgres' ? 'postgresql' : dialect;
86
+ }
87
+
88
+ /**
89
+ * Normalisasi nilai integer non-negatif dari flag.
90
+ * @param {*} value
91
+ * @param {number|null} fallback
92
+ * @returns {number|null}
93
+ */
94
+ function normalizeInt(value, fallback) {
95
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
96
+ return Math.trunc(value);
97
+ }
98
+ return fallback;
99
+ }
100
+
101
+ /**
102
+ * Validasi tiap kolom envelope terhadap metadata SDF (tanpa menyentuh DB).
103
+ *
104
+ * Setiap `envelope.columns[].name` wajib terdeklarasi di SDF dengan tipe canonical
105
+ * yang konsisten. Kolom SDF yang tidak muncul di envelope diperbolehkan (akan
106
+ * memakai default/NULL DB pada INSERT). Mismatch (kolom tak dikenal di SDF, atau
107
+ * tipe berbeda) → throw exitCode=1.
108
+ *
109
+ * @param {Array<{name: string, type: string}>} envelopeColumns
110
+ * @param {Array<{name: string, type: string}>} sdfColumns
111
+ * @param {string} tableName - untuk pesan error
112
+ * @returns {Array<{name: string, type: string}>} kolom yang akan di-INSERT (urutan envelope)
113
+ * @throws {Error} exitCode=1 bila ada kolom tak dikenal atau tipe berbeda
114
+ */
115
+ function matchColumns(envelopeColumns, sdfColumns, tableName) {
116
+ const sdfByName = new Map(sdfColumns.map((c) => [c.name, c]));
117
+ const insertColumns = [];
118
+ for (const ec of envelopeColumns) {
119
+ const sc = sdfByName.get(ec.name);
120
+ if (!sc) {
121
+ throw failWith(
122
+ `Column '${ec.name}' in envelope is not declared in SDF for table '${tableName}'.`,
123
+ 1
124
+ );
125
+ }
126
+ if (sc.type !== ec.type) {
127
+ throw failWith(
128
+ `Column '${ec.name}' type mismatch for table '${tableName}': envelope '${ec.type}' vs SDF '${sc.type}'.`,
129
+ 1
130
+ );
131
+ }
132
+ insertColumns.push({ name: ec.name, type: ec.type });
133
+ }
134
+ return insertColumns;
135
+ }
136
+
137
+ /**
138
+ * Push satu tabel SDF dari file envelope JSON `<input>/<qualifiedName>.json` ke DB.
139
+ *
140
+ * Mengelola executor-nya sendiri (buat → disconnect di finally) agar isolasi
141
+ * per-tabel jelas dan jalur --all-tables dapat memanggilnya berulang tanpa kebocoran
142
+ * koneksi. Metadata kolom, namespace, dan PK murni dari IR.
143
+ *
144
+ * @param {Object} ir - IR model SDF satu tabel
145
+ * @param {Object} ctx - konteks resolusi sekali-pakai
146
+ * @param {Object} ctx.config - config DB hasil loadConfig
147
+ * @param {string} ctx.targetDialect - dialect kanonik (untuk output, dirakit caller)
148
+ * @param {Object} ctx.args - argumen ter-parse (storage-path, batch-size)
149
+ * @param {string} ctx.cwd - working directory
150
+ * @returns {Promise<{table: string, rows: number, committed_batches: number}>}
151
+ */
152
+ async function runOne(ir, ctx) {
153
+ const { config, args, cwd } = ctx;
154
+
155
+ const sdfColumns = irToColumns(ir);
156
+ const { qualifiedName } = getNamespace(ir);
157
+ const rawPk = getPrimaryKey(ir);
158
+ const pk = Array.isArray(rawPk) ? rawPk : [];
159
+
160
+ // ── a. Derive nama file dari SDF (aturan IDENTIK pull) + baca envelope ─────────
161
+ // Nama file = {input}/{qualifiedName}.json, turunan murni dari SDF (bukan --table
162
+ // mentah). Konsisten dengan pull (lihat phase-02b Keputusan K2): tabel ber-schema
163
+ // → `<schema>.<table>.json`; tanpa schema → `<table>.json`. Single-table:
164
+ // file-not-found = exit 1 (tak berubah). Jalur --all-tables menyaring file-missing
165
+ // SEBELUM memanggil runOne (skip-missing), jadi guard ini hanya tercapai pada single.
166
+ const inputDir = path.resolve(cwd, args['storage-path']);
167
+ const inFile = path.join(inputDir, `${qualifiedName}.json`);
168
+ if (!fs.existsSync(inFile)) {
169
+ const rel = path.relative(cwd, inFile).split(path.sep).join('/');
170
+ throw failWith(`Input file not found: ${rel}`, 1);
171
+ }
172
+
173
+ let envelope;
174
+ try {
175
+ const raw = fs.readFileSync(inFile, 'utf8');
176
+ envelope = validateEnvelope(parseEnvelope(raw));
177
+ } catch (e) {
178
+ // parseEnvelope/validateEnvelope sudah ber-exitCode=1; baca file gagal lain → 1.
179
+ if (Number.isInteger(e.exitCode)) throw e;
180
+ throw failWith(`Failed to read envelope '${path.relative(cwd, inFile).split(path.sep).join('/')}': ${e.message}`, 1);
181
+ }
182
+
183
+ // ── b. Column match vs SDF (tanpa introspeksi DB) + no-PK guard ──────────────
184
+ const insertColumns = matchColumns(envelope.columns, sdfColumns, ir.tableName);
185
+
186
+ // No-PK = anomali: schema-validator MEWAJIBKAN primary key, jadi IR yang lolos load
187
+ // SDF pasti punya PK. Bila tetap kosong (mis. IR diinjeksi), gagal jelas lebih aman.
188
+ // Selaras pull-runner (phase-02b Keputusan K3).
189
+ if (pk.length === 0) {
190
+ throw failWith(`Table '${ir.tableName}' has no primary key in SDF (unexpected)`, 1);
191
+ }
192
+
193
+ // ── c. Validasi identifier (anti-injection) + siapkan executor ───────────────
194
+ const dialect = createDialect(config.dialect);
195
+ validateIdentifier(dialect, qualifiedName);
196
+ for (const col of insertColumns) validateIdentifier(dialect, col.name);
197
+
198
+ const batchSize = normalizeInt(args['batch-size'], DEFAULT_BATCH_SIZE) || DEFAULT_BATCH_SIZE;
199
+ const executor = createExecutor(config);
200
+
201
+ // ── d. INSERT batch + commit per batch ───────────────────────────────────────
202
+ // INSERT parameterized per-baris (dirakit via dialect.placeholder), dijalankan
203
+ // per batch dalam SATU executeTransaction → commit per batch. Pilihan per-baris
204
+ // (bukan multi-row VALUES) menjaga paritas lintas-dialect: Oracle tidak mendukung
205
+ // `VALUES (..),(..)` multi-row. Kegagalan satu batch me-rollback batch tsb.;
206
+ // batch sebelumnya tetap ter-commit (risiko partial, didokumentasikan Poin 5/8).
207
+ const colList = insertColumns.map((c) => c.name).join(', ');
208
+ const valuesClause = insertColumns.map((_, i) => placeholder(dialect, i + 1)).join(', ');
209
+ const insertSql = `INSERT INTO ${qualifiedName} (${colList}) VALUES (${valuesClause})`;
210
+
211
+ let rowCount = 0;
212
+ let committedBatches = 0;
213
+ try {
214
+ const rows = envelope.rows;
215
+ for (let start = 0; start < rows.length; start += batchSize) {
216
+ const slice = rows.slice(start, start + batchSize);
217
+ const queries = slice.map((row) => ({
218
+ sql: insertSql,
219
+ params: insertColumns.map((c) => decodeValue(row[c.name], c.type, config.dialect))
220
+ }));
221
+ await executor.executeTransaction(queries);
222
+ committedBatches += 1;
223
+ rowCount += slice.length;
224
+ }
225
+ } catch (e) {
226
+ // exitCode yang sudah di-set dipertahankan; selain itu error lapisan DB
227
+ // (connect/INSERT, termasuk konflik PK pada append) dipetakan ke exit 3.
228
+ if (Number.isInteger(e.exitCode)) throw e;
229
+ throw failWith(`Database error during push: ${e.message}`, 3);
230
+ } finally {
231
+ try { await executor.disconnect(); } catch (_e) { /* ignore */ }
232
+ }
233
+
234
+ // ── e. Ringkasan per-tabel (tanpa command/target_dialect; dirakit caller) ─────
235
+ return {
236
+ table: ir.tableName,
237
+ rows: rowCount,
238
+ committed_batches: committedBatches
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Jalankan command `data push` (dispatcher: single-table vs --all-tables, append-only).
244
+ * @param {Object} args - argumen ter-parse dari contract
245
+ * @returns {Promise<Object>} ringkasan single { command, table, rows, target_dialect, committed_batches }
246
+ * atau agregat { command, all_tables, table_count, total_rows, tables: [...], skipped: [...] }
247
+ */
248
+ async function run(args) {
249
+ const cwd = process.cwd();
250
+
251
+ // ── 1. Resolve config DB tujuan (SEKALI) ─────────────────────────────────────
252
+ const resolved = resolveConfig(args.config, cwd);
253
+ if (!resolved) {
254
+ process.stderr.write(
255
+ "Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs\n"
256
+ );
257
+ throw failWith('--config=<file> is required.', 2);
258
+ }
259
+ if (resolved.source === 'default') {
260
+ printDefaultConfigWarning(resolved.defaultName);
261
+ }
262
+
263
+ let config;
264
+ try {
265
+ config = loadConfig(resolved.path);
266
+ } catch (e) {
267
+ throw failWith(e.message, 2);
268
+ }
269
+
270
+ // ── 2. Validasi mutual-exclusive --table / --all-tables (SEBELUM SDF/DB) ──────
271
+ // Arg-parser tidak punya mekanisme XOR lintas-flag (hanya `required` per-flag),
272
+ // sehingga "tepat satu" ditegakkan di sini. Exit 2 (precondition/usage). Identik
273
+ // pull-runner Phase 02.
274
+ const hasTable = args.table != null && args.table !== '';
275
+ const hasAll = args['all-tables'] === true;
276
+ if (hasTable && hasAll) {
277
+ throw failWith('Specify exactly one of --table or --all-tables.', 2);
278
+ }
279
+ if (!hasTable && !hasAll) {
280
+ throw failWith('Specify either --table=<name> or --all-tables.', 2);
281
+ }
282
+
283
+ // ── 3. Load SDF (SEKALI) ─────────────────────────────────────────────────────
284
+ const schemaPath = args['schema-path'];
285
+ const absSchemaPath = path.resolve(cwd, schemaPath);
286
+ let models;
287
+ try {
288
+ models = loadSdf(absSchemaPath);
289
+ } catch (e) {
290
+ // Error baca SDF (path tidak ada, parse gagal) = operational.
291
+ throw failWith(e.message, 1);
292
+ }
293
+
294
+ const ctx = {
295
+ config,
296
+ targetDialect: canonicalTargetDialect(config.dialect),
297
+ args,
298
+ cwd
299
+ };
300
+
301
+ // ── 4a. Jalur --table (single; regresi nol) ──────────────────────────────────
302
+ if (hasTable) {
303
+ const ir = findTableOrThrow(models, args.table); // throw exitCode=2 bila tak terdaftar/ambigu
304
+ const per = await runOne(ir, ctx);
305
+ const summary = {
306
+ command: 'push',
307
+ table: per.table,
308
+ rows: per.rows,
309
+ target_dialect: ctx.targetDialect,
310
+ committed_batches: per.committed_batches
311
+ };
312
+
313
+ if (args.json === true) {
314
+ process.stdout.write(JSON.stringify(summary) + '\n');
315
+ } else {
316
+ process.stdout.write(
317
+ `Pushed ${per.rows} rows to ${per.table} (dialect: ${ctx.targetDialect}, batches: ${per.committed_batches})\n`
318
+ );
319
+ }
320
+ return summary;
321
+ }
322
+
323
+ // ── 4b. Jalur --all-tables (topo-sort FK parent→child, fail-fast Q3, skip-missing) ──
324
+ // Urutan push diturunkan dari FK SDF: parent (tabel direferensikan) SEBELUM child
325
+ // agar INSERT child tidak melanggar FK constraint. Siklus → fallback urutan deklarasi.
326
+ const { ordered, cycle } = topoSortByFk(models);
327
+ if (cycle) {
328
+ process.stderr.write('FK cycle detected; falling back to declaration order\n');
329
+ }
330
+
331
+ // Skip-missing HANYA di --all-tables: tabel tanpa file input dilewati (warning +
332
+ // catat), bukan error. Catatan (Phase 05): bila parent di-skip namun child ada,
333
+ // INSERT child dapat melanggar FK → muncul sebagai DB error exit 3 (fail-fast).
334
+ const inputDir = path.resolve(cwd, args['storage-path']);
335
+ const tables = [];
336
+ const skipped = [];
337
+ let totalRows = 0;
338
+
339
+ for (const ir of ordered) {
340
+ const { qualifiedName } = getNamespace(ir);
341
+ const inFile = path.join(inputDir, `${qualifiedName}.json`);
342
+ if (!fs.existsSync(inFile)) {
343
+ process.stderr.write(`Skipped ${ir.tableName}: input file not found\n`);
344
+ skipped.push(ir.tableName);
345
+ continue;
346
+ }
347
+
348
+ const per = await runOne(ir, ctx); // fail-fast (Q3)
349
+ tables.push(per);
350
+ totalRows += per.rows;
351
+ if (args.json !== true) {
352
+ process.stdout.write(
353
+ `Pushed ${per.rows} rows to ${per.table} (dialect: ${ctx.targetDialect}, batches: ${per.committed_batches})\n`
354
+ );
355
+ }
356
+ }
357
+
358
+ const aggregate = {
359
+ command: 'push',
360
+ all_tables: true,
361
+ table_count: tables.length,
362
+ total_rows: totalRows,
363
+ tables,
364
+ skipped
365
+ };
366
+
367
+ if (args.json === true) {
368
+ process.stdout.write(JSON.stringify(aggregate) + '\n');
369
+ } else {
370
+ process.stdout.write(
371
+ `Done: ${tables.length} tables, ${totalRows} rows total (skipped: ${skipped.length})\n`
372
+ );
373
+ }
374
+
375
+ return aggregate;
376
+ }
377
+
378
+ module.exports = {
379
+ run,
380
+ // Diekspor untuk reuse/test internal.
381
+ _internal: { runOne, matchColumns, normalizeInt, canonicalTargetDialect }
382
+ };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * sdf-reader — Sumber metadata tabel untuk `data pull` / `data push`.
5
+ *
6
+ * Seluruh metadata (daftar kolom, tipe canonical, nullable, primary key composite)
7
+ * dibaca murni dari SDF IR (Schema Definition File), TANPA introspeksi DB. Modul ini
8
+ * mereuse loader existing `dbschema-kit/loader` dan tidak membuka koneksi database.
9
+ *
10
+ * Keputusan arah: amandemen A2 (SDF sebagai sumber kebenaran tabel) dan A3 (deteksi
11
+ * binary by-omission — SDF hanya bisa menyimpan 10 tipe canonical, jadi kolom binary
12
+ * mustahil muncul). Lihat docs/plan/data-pull-push/data-pull-push-09-amendments.md.
13
+ *
14
+ * @module generators/lib/data/sdf-reader
15
+ */
16
+
17
+ const { loadSchemaPath } = require('../dbschema-kit/loader');
18
+
19
+ /**
20
+ * Muat SDF dari file atau folder menjadi Map<qualifiedName, IR>.
21
+ *
22
+ * Delegasi langsung ke loader existing — tidak ada introspeksi DB. `schemaPath`
23
+ * boleh menunjuk satu file `.js` atau folder berisi banyak file SDF.
24
+ *
25
+ * @param {string} schemaPath - Path ke file atau folder SDF
26
+ * @returns {Map<string, Object>} Map qualifiedName → IR model
27
+ */
28
+ function loadSdf(schemaPath) {
29
+ return loadSchemaPath(schemaPath);
30
+ }
31
+
32
+ /**
33
+ * Cari satu IR dari Map hasil loadSdf berdasarkan argumen tabel.
34
+ *
35
+ * Urutan pencocokan:
36
+ * 1. Match eksak via qualifiedName (key Map) → selalu satu entry, tidak ambigu.
37
+ * 2. Match via `ir.tableName` (arg tanpa schema). Bila cocok dengan LEBIH DARI SATU
38
+ * entry (nama tabel sama di schema berbeda), arg dianggap ambigu → throw exitCode=2
39
+ * yang meminta bentuk qualified `schema.table`.
40
+ *
41
+ * Mereuse pola table-guard di cli/schema/apply.js:303-314. Bila tidak ditemukan,
42
+ * throw Error dengan `err.exitCode = 2` (precondition) — penolakan terjadi SEBELUM
43
+ * koneksi DB dibuka oleh caller.
44
+ *
45
+ * @param {Map<string, Object>} models - Hasil loadSdf
46
+ * @param {string} tableArg - Nilai flag --table
47
+ * @returns {Object} IR model yang cocok
48
+ * @throws {Error} exitCode=2 bila tabel tidak terdaftar ATAU ambigu lintas-schema
49
+ */
50
+ function findTableOrThrow(models, tableArg) {
51
+ // 1. Match eksak via qualifiedName (key Map) — kunci Map unik, selalu satu entry.
52
+ if (models.has(tableArg)) {
53
+ return models.get(tableArg);
54
+ }
55
+
56
+ // 2. Match via tableName tanpa schema — bisa lebih dari satu bila nama tabel sama
57
+ // di schema berbeda.
58
+ const byTableName = [];
59
+ for (const ir of models.values()) {
60
+ if (ir.tableName === tableArg) {
61
+ byTableName.push(ir);
62
+ }
63
+ }
64
+
65
+ if (byTableName.length === 1) {
66
+ return byTableName[0];
67
+ }
68
+ if (byTableName.length > 1) {
69
+ const err = new Error(
70
+ `Table '${tableArg}' is ambiguous across schemas; specify 'schema.table'.`
71
+ );
72
+ err.exitCode = 2;
73
+ throw err;
74
+ }
75
+
76
+ const err = new Error(`Table '${tableArg}' not found in SDF.`);
77
+ err.exitCode = 2;
78
+ throw err;
79
+ }
80
+
81
+ /**
82
+ * Transformasi IR menjadi array kolom envelope.
83
+ *
84
+ * Murni in-memory, tanpa I/O. Urutan kolom mengikuti urutan deklarasi field di SDF
85
+ * (`Object.keys(ir.fields)`).
86
+ *
87
+ * @param {Object} ir - IR model dari SDF
88
+ * @returns {Array<{name: string, type: string, nullable: boolean, primary_key: boolean}>}
89
+ */
90
+ function irToColumns(ir) {
91
+ const primaryKey = Array.isArray(ir.primaryKey) ? ir.primaryKey : [];
92
+ return Object.keys(ir.fields).map((name) => {
93
+ const field = ir.fields[name];
94
+ return {
95
+ name,
96
+ type: field.type,
97
+ nullable: field.notnull !== true,
98
+ primary_key: primaryKey.includes(name)
99
+ };
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Ambil namespace (schema DB) dari IR.
105
+ *
106
+ * @param {Object} ir - IR model dari SDF
107
+ * @returns {{schemaName: (string|null), qualifiedName: string}}
108
+ */
109
+ function getNamespace(ir) {
110
+ return {
111
+ schemaName: ir.schemaName || null,
112
+ qualifiedName: ir.qualifiedName || ir.tableName
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Ambil daftar primary key (mendukung composite).
118
+ *
119
+ * @param {Object} ir - IR model dari SDF
120
+ * @returns {string[]} Array nama kolom primary key
121
+ */
122
+ function getPrimaryKey(ir) {
123
+ return ir.primaryKey;
124
+ }
125
+
126
+ module.exports = {
127
+ loadSdf,
128
+ findTableOrThrow,
129
+ irToColumns,
130
+ getNamespace,
131
+ getPrimaryKey
132
+ };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * table-order — Topological sort tabel SDF berdasarkan foreign key (parent→child).
5
+ *
6
+ * `data push` bersifat append (INSERT), sehingga tabel CHILD (pemilik kolom FK)
7
+ * wajib di-push SETELAH tabel PARENT (tabel yang direferensikan), agar INSERT child
8
+ * tidak melanggar FK constraint. Helper ini menurunkan urutan tersebut MURNI dari
9
+ * IR SDF, tanpa I/O maupun koneksi DB.
10
+ *
11
+ * Edge dipungut dari `ir.fields[<col>].fk.table` (arah: parent SEBELUM child). FK
12
+ * target selalu nama tabel polos (shorthand `fk:` tidak mendukung lintas-schema —
13
+ * lihat phase-00-discovery Deliverable 1), jadi pemetaan `fk.table → IR` dilakukan
14
+ * via index `tableName → IR`.
15
+ *
16
+ * Penanganan kasus tepi:
17
+ * - Self-FK (`fk.table === ir.tableName`) → edge diabaikan (bukan siklus).
18
+ * - Target tak dikenal (tak ada di `models`, R2) → edge diabaikan (jangan crash).
19
+ * - `tableName` duplikat antar-schema (R1) → ambiguous; edge yang menunjuk
20
+ * nama itu diabaikan (no-edge). v2 berasumsi single-namespace.
21
+ * - Siklus FK (R5) → `cycle:true`; `ordered` =
22
+ * urutan deklarasi (insertion order Map). Runner mencetak warning + fallback.
23
+ *
24
+ * @module generators/lib/data/table-order
25
+ */
26
+
27
+ /**
28
+ * Identitas node = qualifiedName (kunci unik Map). `tableName` bisa duplikat antar
29
+ * schema, sehingga tidak aman dipakai sebagai identitas node.
30
+ * @param {Object} ir
31
+ * @returns {string}
32
+ */
33
+ function nodeKeyOf(ir) {
34
+ return ir.qualifiedName || ir.tableName;
35
+ }
36
+
37
+ /**
38
+ * Urutkan tabel secara topologis berdasarkan FK (Kahn's algorithm).
39
+ *
40
+ * @param {Map<string, Object>} models - Map<qualifiedName, IR> hasil loadSdf.
41
+ * @returns {{ ordered: Object[], cycle: boolean }} `ordered` = IR parent→child
42
+ * (atau urutan deklarasi bila siklus); `cycle` = true bila siklus terdeteksi.
43
+ */
44
+ function topoSortByFk(models) {
45
+ const irs = [...models.values()];
46
+
47
+ // Index tableName → IR. Bila >1 IR berbagi tableName (duplikat antar-schema, R1),
48
+ // nama itu ambiguous: edge yang menargetkannya akan diabaikan (no-edge).
49
+ const byTableName = new Map();
50
+ const ambiguous = new Set();
51
+ for (const ir of irs) {
52
+ const tn = ir.tableName;
53
+ if (byTableName.has(tn)) {
54
+ ambiguous.add(tn);
55
+ } else {
56
+ byTableName.set(tn, ir);
57
+ }
58
+ }
59
+
60
+ // Graph: node = nodeKey (qualifiedName). `indegree` = jumlah parent; `children`
61
+ // = parentKey → Set<childKey>. Inisialisasi semua node dengan indegree 0.
62
+ const indegree = new Map();
63
+ const children = new Map();
64
+ for (const ir of irs) {
65
+ indegree.set(nodeKeyOf(ir), 0);
66
+ children.set(nodeKeyOf(ir), new Set());
67
+ }
68
+
69
+ // Pungut edge unik (parent → child) dari fk.table tiap field.
70
+ for (const childIr of irs) {
71
+ const childKey = nodeKeyOf(childIr);
72
+ for (const fieldName of Object.keys(childIr.fields)) {
73
+ const field = childIr.fields[fieldName];
74
+ const fk = field && field.fk;
75
+ if (!fk || !fk.table) continue;
76
+
77
+ const parentName = fk.table; // nama tabel polos
78
+
79
+ // Self-FK → bukan siklus, abaikan edge.
80
+ if (parentName === childIr.tableName) continue;
81
+ // Target tak dikenal (SDF parsial, R2) → abaikan edge.
82
+ if (!byTableName.has(parentName)) continue;
83
+ // Duplikat antar-schema (R1) → ambiguous, perlakukan sebagai no-edge.
84
+ if (ambiguous.has(parentName)) continue;
85
+
86
+ const parentKey = nodeKeyOf(byTableName.get(parentName));
87
+ // Self lewat qualifiedName (defensif) → abaikan.
88
+ if (parentKey === childKey) continue;
89
+
90
+ // Edge ganda (mis. dua kolom FK ke tabel sama) dihitung sekali.
91
+ if (!children.get(parentKey).has(childKey)) {
92
+ children.get(parentKey).add(childKey);
93
+ indegree.set(childKey, indegree.get(childKey) + 1);
94
+ }
95
+ }
96
+ }
97
+
98
+ // Kahn: antrekan node indegree 0 dalam URUTAN DEKLARASI (insertion order Map),
99
+ // proses children juga dalam urutan penyisipan (= urutan deklarasi child), agar
100
+ // hasil deterministik dan stabil untuk tabel tanpa FK.
101
+ const irByKey = new Map(irs.map((ir) => [nodeKeyOf(ir), ir]));
102
+ const queue = [];
103
+ for (const ir of irs) {
104
+ if (indegree.get(nodeKeyOf(ir)) === 0) queue.push(nodeKeyOf(ir));
105
+ }
106
+
107
+ const orderedKeys = [];
108
+ while (queue.length > 0) {
109
+ const key = queue.shift();
110
+ orderedKeys.push(key);
111
+ for (const childKey of children.get(key)) {
112
+ const next = indegree.get(childKey) - 1;
113
+ indegree.set(childKey, next);
114
+ if (next === 0) queue.push(childKey);
115
+ }
116
+ }
117
+
118
+ // Bila tidak semua node ter-emit, ada siklus (R5): fallback urutan deklarasi.
119
+ if (orderedKeys.length !== irs.length) {
120
+ return { ordered: irs.slice(), cycle: true };
121
+ }
122
+
123
+ return { ordered: orderedKeys.map((key) => irByKey.get(key)), cycle: false };
124
+ }
125
+
126
+ module.exports = { topoSortByFk };