@restforgejs/platform 5.0.9 → 5.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/build-info.json +2 -2
  2. package/cli/consumer-deploy.js +1 -1
  3. package/cli/consumer.js +1 -1
  4. package/generators/cli/data/pull.js +95 -0
  5. package/generators/cli/data/push.js +85 -0
  6. package/generators/cli/fast-track.js +950 -0
  7. package/generators/cli/payload/sync.js +18 -2
  8. package/generators/cli/schema/introspect.js +10 -10
  9. package/generators/lib/data/db-executor.js +440 -0
  10. package/generators/lib/data/dialect-kit.js +56 -0
  11. package/generators/lib/data/envelope.js +220 -0
  12. package/generators/lib/data/pull-runner.js +407 -0
  13. package/generators/lib/data/push-runner.js +382 -0
  14. package/generators/lib/data/sdf-reader.js +132 -0
  15. package/generators/lib/data/table-order.js +126 -0
  16. package/generators/lib/data/value-codec.js +188 -0
  17. package/generators/lib/migrate/field-type-resolver.js +18 -5
  18. package/generators/lib/payload/payload-runner.js +724 -39
  19. package/generators/lib/templates/dashboard-catalog.js +1 -1
  20. package/generators/lib/templates/db-connection-env.js +1 -1
  21. package/generators/lib/templates/dbschema-catalog.js +1 -1
  22. package/generators/lib/templates/field-validation-catalog.js +1 -1
  23. package/generators/lib/templates/mysql-template.js +1 -1
  24. package/generators/lib/templates/oracle-template.js +1 -1
  25. package/generators/lib/templates/postgres-template.js +1 -1
  26. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  27. package/generators/lib/templates/sqlite-template.js +1 -1
  28. package/integrity-manifest.json +18 -18
  29. package/package.json +1 -1
  30. package/scripts/verify-integrity.js +1 -1
  31. package/server.js +1 -1
  32. package/src/components/handlers/adjust_handler.js +1 -1
  33. package/src/components/handlers/audit_handler.js +1 -1
  34. package/src/components/handlers/delete_handler.js +1 -1
  35. package/src/components/handlers/export_handler.js +1 -1
  36. package/src/components/handlers/import_handler.js +1 -1
  37. package/src/components/handlers/insert_handler.js +1 -1
  38. package/src/components/handlers/update_handler.js +1 -1
  39. package/src/components/handlers/upload_handler.js +1 -1
  40. package/src/components/handlers/workflow_handler.js +1 -1
  41. package/src/components/integrations/webhook.js +1 -1
  42. package/src/consumers/baseConsumer.js +1 -1
  43. package/src/consumers/declarativeMapper.js +1 -1
  44. package/src/consumers/handlers/apiHandler.js +1 -1
  45. package/src/consumers/handlers/consoleHandler.js +1 -1
  46. package/src/consumers/handlers/databaseHandler.js +1 -1
  47. package/src/consumers/handlers/index.js +1 -1
  48. package/src/consumers/handlers/kafkaHandler.js +1 -1
  49. package/src/consumers/index.js +1 -1
  50. package/src/consumers/messageTransformer.js +1 -1
  51. package/src/consumers/validator.js +1 -1
  52. package/src/core/db/dialect/base-dialect.js +1 -1
  53. package/src/core/db/dialect/index.js +1 -1
  54. package/src/core/db/dialect/mysql-dialect.js +1 -1
  55. package/src/core/db/dialect/oracle-dialect.js +1 -1
  56. package/src/core/db/dialect/postgres-dialect.js +1 -1
  57. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  58. package/src/core/db/flatten-helper.js +1 -1
  59. package/src/core/db/query-builder-error.js +1 -1
  60. package/src/core/db/query-builder.js +1 -1
  61. package/src/core/db/relation-helper.js +1 -1
  62. package/src/core/handlers/delete_handler.js +1 -1
  63. package/src/core/handlers/insert_handler.js +1 -1
  64. package/src/core/handlers/update_handler.js +1 -1
  65. package/src/core/models/base-model.js +1 -1
  66. package/src/core/utils/cache-manager.js +1 -1
  67. package/src/core/utils/component-engine.js +1 -1
  68. package/src/core/utils/context-builder.js +1 -1
  69. package/src/core/utils/datetime-formatter.js +1 -1
  70. package/src/core/utils/datetime-parser.js +1 -1
  71. package/src/core/utils/db.js +1 -1
  72. package/src/core/utils/logger.js +1 -1
  73. package/src/core/utils/payload-loader.js +1 -1
  74. package/src/core/utils/security-checks.js +1 -1
  75. package/src/middleware/body-options.js +1 -1
  76. package/src/middleware/cors.js +1 -1
  77. package/src/middleware/idempotency.js +1 -1
  78. package/src/middleware/rate-limiter.js +1 -1
  79. package/src/middleware/request-logger.js +1 -1
  80. package/src/middleware/security-headers.js +1 -1
  81. package/src/models/base-model-mysql.js +1 -1
  82. package/src/models/base-model-oracle.js +1 -1
  83. package/src/models/base-model-sqlite.js +1 -1
  84. package/src/models/base-model.js +1 -1
  85. package/src/pro/caching/redis-client.js +1 -1
  86. package/src/pro/caching/redis-helper.js +1 -1
  87. package/src/pro/consumers/baseConsumer.js +1 -1
  88. package/src/pro/consumers/declarativeMapper.js +1 -1
  89. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  90. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  91. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  92. package/src/pro/consumers/handlers/index.js +1 -1
  93. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  94. package/src/pro/consumers/index.js +1 -1
  95. package/src/pro/consumers/messageTransformer.js +1 -1
  96. package/src/pro/consumers/validator.js +1 -1
  97. package/src/pro/database/base-model-mysql.js +1 -1
  98. package/src/pro/database/base-model-oracle.js +1 -1
  99. package/src/pro/database/base-model-sqlite.js +1 -1
  100. package/src/pro/database/db-mysql.js +1 -1
  101. package/src/pro/database/db-oracle.js +1 -1
  102. package/src/pro/database/db-sqlite.js +1 -1
  103. package/src/pro/excel/excel-generator.js +1 -1
  104. package/src/pro/excel/excel-parser.js +1 -1
  105. package/src/pro/excel/export-service.js +1 -1
  106. package/src/pro/excel/export_handler.js +1 -1
  107. package/src/pro/excel/import-service.js +1 -1
  108. package/src/pro/excel/import-validator.js +1 -1
  109. package/src/pro/excel/import_handler.js +1 -1
  110. package/src/pro/excel/upsert-builder.js +1 -1
  111. package/src/pro/idgen/idgen-routes.js +1 -1
  112. package/src/pro/integrations/lookup-resolver.js +1 -1
  113. package/src/pro/integrations/upload-handler-v2.js +1 -1
  114. package/src/pro/integrations/upload-handler.js +1 -1
  115. package/src/pro/integrations/webhook.js +1 -1
  116. package/src/pro/locking/lock-routes.js +1 -1
  117. package/src/pro/locking/resource-lock-manager.js +1 -1
  118. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  119. package/src/pro/messaging/kafkaService.js +1 -1
  120. package/src/pro/messaging/messagehubService.js +1 -1
  121. package/src/pro/messaging/rabbitmqService.js +1 -1
  122. package/src/pro/scheduler/job-manager.js +1 -1
  123. package/src/pro/scheduler/job-routes.js +1 -1
  124. package/src/pro/scheduler/job-validator.js +1 -1
  125. package/src/pro/storage/base-storage-provider.js +1 -1
  126. package/src/pro/storage/file-metadata-helper.js +1 -1
  127. package/src/pro/storage/index.js +1 -1
  128. package/src/pro/storage/local-storage-provider.js +1 -1
  129. package/src/pro/storage/s3-storage-provider.js +1 -1
  130. package/src/pro/storage/upload-cleanup-job.js +1 -1
  131. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  132. package/src/pro/storage/upload-pending-tracker.js +1 -1
  133. package/src/pro/websocket/broadcast-helper.js +1 -1
  134. package/src/pro/websocket/index.js +1 -1
  135. package/src/pro/websocket/livesync-server.js +1 -1
  136. package/src/pro/websocket/ws-broadcaster.js +1 -1
  137. package/src/services/export-service.js +1 -1
  138. package/src/services/import-service.js +1 -1
  139. package/src/services/kafkaConsumerService.js +1 -1
  140. package/src/services/kafkaService.js +1 -1
  141. package/src/services/messagehubService.js +1 -1
  142. package/src/services/rabbitmqService.js +1 -1
  143. package/src/utils/cache-invalidation-registry.js +1 -1
  144. package/src/utils/cache-manager.js +1 -1
  145. package/src/utils/component-engine.js +1 -1
  146. package/src/utils/config-extractor.js +1 -1
  147. package/src/utils/consumerLogger.js +1 -1
  148. package/src/utils/context-builder.js +1 -1
  149. package/src/utils/dashboard-helpers.js +1 -1
  150. package/src/utils/dateHelper.js +1 -1
  151. package/src/utils/datetime-formatter.js +1 -1
  152. package/src/utils/datetime-parser.js +1 -1
  153. package/src/utils/db-bootstrap.js +1 -1
  154. package/src/utils/db-mysql.js +1 -1
  155. package/src/utils/db-oracle.js +1 -1
  156. package/src/utils/db-sqlite.js +1 -1
  157. package/src/utils/db.js +1 -1
  158. package/src/utils/demo-generator.js +1 -1
  159. package/src/utils/excel-generator.js +1 -1
  160. package/src/utils/excel-parser.js +1 -1
  161. package/src/utils/file-watcher.js +1 -1
  162. package/src/utils/id-generator.js +1 -1
  163. package/src/utils/idempotency-manager.js +1 -1
  164. package/src/utils/import-validator.js +1 -1
  165. package/src/utils/license-client.js +1 -1
  166. package/src/utils/lock-manager.js +1 -1
  167. package/src/utils/logger.js +1 -1
  168. package/src/utils/lookup-resolver.js +1 -1
  169. package/src/utils/payload-loader.js +1 -1
  170. package/src/utils/processor-response.js +1 -1
  171. package/src/utils/rabbitmq.js +1 -1
  172. package/src/utils/redis-client.js +1 -1
  173. package/src/utils/redis-helper.js +1 -1
  174. package/src/utils/request-scope.js +1 -1
  175. package/src/utils/security-checks.js +1 -1
  176. package/src/utils/service-resolver.js +1 -1
  177. package/src/utils/shutdown-coordinator.js +1 -1
  178. package/src/utils/trusted-keys.js +1 -1
  179. package/src/utils/upload-handler.js +1 -1
  180. package/src/utils/upsert-builder.js +1 -1
  181. package/src/utils/workflow-hook-executor.js +1 -1
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * envelope — Bentuk + I/O envelope file data.
5
+ *
6
+ * Struktur envelope (urutan key wajib dipertahankan):
7
+ * { format_version, table, source_dialect, columns, rows }
8
+ *
9
+ * Mengacu pada docs/plan/data-pull-push/data-pull-push-03-data-format.md.
10
+ *
11
+ * @module generators/lib/data/envelope
12
+ */
13
+
14
+ const FORMAT_VERSION = '1.0';
15
+
16
+ /**
17
+ * Bangun header envelope (tanpa rows).
18
+ *
19
+ * @param {Object} args
20
+ * @param {string} args.table - Nama tabel
21
+ * @param {string} args.sourceDialect - Dialect sumber (mis. 'postgresql')
22
+ * @param {Array<Object>} args.columns - Daftar kolom { name, type, nullable, primary_key }
23
+ * @returns {{format_version: string, table: string, source_dialect: string, columns: Array}}
24
+ */
25
+ function buildHeader({ table, sourceDialect, columns }) {
26
+ return {
27
+ format_version: FORMAT_VERSION,
28
+ table,
29
+ source_dialect: sourceDialect,
30
+ columns
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Resolve sink penulisan menjadi fungsi write(chunk).
36
+ * Menerima: fungsi, object dengan method `write`, atau undefined (buffer mode).
37
+ *
38
+ * @param {Function|{write: Function}|undefined} sink
39
+ * @returns {{write: Function, buffer: (string[]|null)}}
40
+ */
41
+ function resolveSink(sink) {
42
+ if (sink === undefined || sink === null) {
43
+ const buffer = [];
44
+ return { write: (chunk) => buffer.push(chunk), buffer };
45
+ }
46
+ if (typeof sink === 'function') {
47
+ return { write: sink, buffer: null };
48
+ }
49
+ if (typeof sink.write === 'function') {
50
+ return { write: (chunk) => sink.write(chunk), buffer: null };
51
+ }
52
+ throw new Error('EnvelopeWriter: sink harus berupa fungsi, object dengan method write(), atau undefined');
53
+ }
54
+
55
+ /**
56
+ * Penulis envelope streaming.
57
+ *
58
+ * Menulis header, meng-append rows per batch, lalu menutup array `rows` dan envelope.
59
+ * Cocok untuk tabel besar karena rows tidak perlu ditahan seluruhnya di memori.
60
+ * Output akhir adalah JSON valid dengan urutan key:
61
+ * format_version, table, source_dialect, columns, rows
62
+ *
63
+ * Penggunaan:
64
+ * const w = new EnvelopeWriter(fs.createWriteStream(path));
65
+ * w.writeHeader({ table, sourceDialect, columns });
66
+ * w.appendRows(batch1);
67
+ * w.appendRows(batch2);
68
+ * w.end();
69
+ *
70
+ * Tanpa sink (buffer mode), `end()` mengembalikan string JSON penuh.
71
+ */
72
+ class EnvelopeWriter {
73
+ /**
74
+ * @param {Function|{write: Function}} [sink] - Tujuan tulis; kosong = buffer mode
75
+ */
76
+ constructor(sink) {
77
+ const resolved = resolveSink(sink);
78
+ this._write = resolved.write;
79
+ this._buffer = resolved.buffer;
80
+ this._state = 'init'; // init → header → ended
81
+ this._rowsWritten = 0;
82
+ }
83
+
84
+ /**
85
+ * Tulis prefix header dan buka array `rows`.
86
+ * @param {Object} header - { table, sourceDialect, columns }
87
+ * @returns {EnvelopeWriter} this
88
+ */
89
+ writeHeader({ table, sourceDialect, columns }) {
90
+ if (this._state !== 'init') {
91
+ throw new Error('EnvelopeWriter: writeHeader() hanya boleh dipanggil sekali di awal');
92
+ }
93
+ const prefix =
94
+ '{' +
95
+ `"format_version":${JSON.stringify(FORMAT_VERSION)},` +
96
+ `"table":${JSON.stringify(table)},` +
97
+ `"source_dialect":${JSON.stringify(sourceDialect)},` +
98
+ `"columns":${JSON.stringify(columns)},` +
99
+ '"rows":[';
100
+ this._write(prefix);
101
+ this._state = 'header';
102
+ return this;
103
+ }
104
+
105
+ /**
106
+ * Append satu batch rows ke array `rows`.
107
+ * @param {Array<Object>} rows
108
+ * @returns {EnvelopeWriter} this
109
+ */
110
+ appendRows(rows) {
111
+ if (this._state !== 'header') {
112
+ throw new Error('EnvelopeWriter: appendRows() harus dipanggil setelah writeHeader() dan sebelum end()');
113
+ }
114
+ if (!Array.isArray(rows)) {
115
+ throw new Error('EnvelopeWriter: appendRows() membutuhkan array rows');
116
+ }
117
+ for (const row of rows) {
118
+ const sep = this._rowsWritten === 0 ? '' : ',';
119
+ this._write(sep + JSON.stringify(row));
120
+ this._rowsWritten++;
121
+ }
122
+ return this;
123
+ }
124
+
125
+ /**
126
+ * Tutup array `rows` dan envelope.
127
+ * @returns {string|undefined} String JSON penuh bila buffer mode, selain itu undefined
128
+ */
129
+ end() {
130
+ if (this._state === 'init') {
131
+ throw new Error('EnvelopeWriter: end() dipanggil sebelum writeHeader()');
132
+ }
133
+ if (this._state === 'ended') {
134
+ throw new Error('EnvelopeWriter: end() sudah dipanggil');
135
+ }
136
+ this._write(']}');
137
+ this._state = 'ended';
138
+ if (this._buffer) {
139
+ return this._buffer.join('');
140
+ }
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Parse string JSON envelope menjadi object.
147
+ *
148
+ * @param {string} jsonString
149
+ * @returns {Object} Object envelope
150
+ * @throws {Error} exitCode=1 bila JSON tidak dapat di-parse
151
+ */
152
+ function parseEnvelope(jsonString) {
153
+ try {
154
+ return JSON.parse(jsonString);
155
+ } catch (err) {
156
+ const e = new Error(`Envelope tidak dapat di-parse sebagai JSON: ${err.message}`);
157
+ e.exitCode = 1;
158
+ throw e;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Validasi shape envelope. Throw (exitCode=1) bila tidak valid.
164
+ *
165
+ * Wajib: format_version (ada), table (string), columns (array of {name,type}),
166
+ * rows (array of object).
167
+ *
168
+ * @param {Object} obj
169
+ * @returns {Object} obj yang sama (untuk chaining)
170
+ * @throws {Error} exitCode=1 bila shape tidak valid
171
+ */
172
+ function validateEnvelope(obj) {
173
+ const fail = (msg) => {
174
+ const e = new Error(`Envelope tidak valid: ${msg}`);
175
+ e.exitCode = 1;
176
+ throw e;
177
+ };
178
+
179
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
180
+ fail('harus berupa object');
181
+ }
182
+ if (obj.format_version === undefined || obj.format_version === null) {
183
+ fail('field "format_version" wajib ada');
184
+ }
185
+ if (typeof obj.table !== 'string' || obj.table === '') {
186
+ fail('field "table" wajib berupa string non-kosong');
187
+ }
188
+ if (!Array.isArray(obj.columns)) {
189
+ fail('field "columns" wajib berupa array');
190
+ }
191
+ obj.columns.forEach((col, i) => {
192
+ if (col === null || typeof col !== 'object' || Array.isArray(col)) {
193
+ fail(`columns[${i}] harus berupa object`);
194
+ }
195
+ if (typeof col.name !== 'string' || col.name === '') {
196
+ fail(`columns[${i}].name wajib berupa string non-kosong`);
197
+ }
198
+ if (typeof col.type !== 'string' || col.type === '') {
199
+ fail(`columns[${i}].type wajib berupa string non-kosong`);
200
+ }
201
+ });
202
+ if (!Array.isArray(obj.rows)) {
203
+ fail('field "rows" wajib berupa array');
204
+ }
205
+ obj.rows.forEach((row, i) => {
206
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
207
+ fail(`rows[${i}] harus berupa object`);
208
+ }
209
+ });
210
+
211
+ return obj;
212
+ }
213
+
214
+ module.exports = {
215
+ FORMAT_VERSION,
216
+ buildHeader,
217
+ EnvelopeWriter,
218
+ parseEnvelope,
219
+ validateEnvelope
220
+ };
@@ -0,0 +1,407 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * pull-runner — Business logic command `data pull`.
5
+ *
6
+ * `run()` adalah dispatcher (single vs all-tables); `runOne(ir, ctx)` mengeksekusi
7
+ * pull satu tabel dan mengelola executor-nya sendiri (Phase 02, data-pull-push-v2).
8
+ *
9
+ * Alur dispatcher `run()`:
10
+ * 1. Resolve config DB (config-resolver) → loadConfig (dbschema-kit/connection); SEKALI.
11
+ * 2. Validasi mutual-exclusive (sebelum load SDF / koneksi DB): tepat satu dari
12
+ * --table / --all-tables (keduanya / tidak keduanya → exit 2).
13
+ * 3. Validasi --format (hanya 'json').
14
+ * 4. Load SDF SEKALI → Map<qualifiedName, IR> (sdf-reader).
15
+ * 5a. Jalur --table: findTableOrThrow → runOne(ir, ctx) → ringkasan single.
16
+ * 5b. Jalur --all-tables: iterasi models.values() FAIL-FAST (Q3) → runOne per IR →
17
+ * ringkasan agregat. Tabel yang sudah selesai sebelum error tetap tertulis.
18
+ *
19
+ * Alur `runOne(ir, ctx)` (per tabel; metadata kolom/namespace/PK murni dari SDF IR):
20
+ * a. Cek file output; tolak bila sudah ada tanpa --force (sebelum konek/tulis).
21
+ * b. Validasi identifier tabel/kolom (anti-injection) + no-PK guard.
22
+ * c. Connect (db-executor) + SELECT bertahap (keyset PK tunggal, OFFSET fallback composite).
23
+ * d. Tulis envelope streaming (EnvelopeWriter) + encode nilai per kolom (value-codec).
24
+ * e. Disconnect (selalu) + cleanup file partial saat error.
25
+ *
26
+ * Exit code: 0 sukses; 1 operational (file exists, baca SDF gagal); 2 usage/precondition
27
+ * (--config wajib, mutual-exclusive, tabel tak terdaftar, --format invalid); 3 database
28
+ * error. Handler tidak memanggil process.exit() sendiri.
29
+ *
30
+ * @module generators/lib/data/pull-runner
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
37
+ const { loadConfig } = require('../dbschema-kit/connection');
38
+ const {
39
+ loadSdf,
40
+ findTableOrThrow,
41
+ irToColumns,
42
+ getNamespace,
43
+ getPrimaryKey
44
+ } = require('./sdf-reader');
45
+ const { createDialect, validateIdentifier } = require('./dialect-kit');
46
+ const { createExecutor } = require('./db-executor');
47
+ const { encodeValue } = require('./value-codec');
48
+ const { EnvelopeWriter } = require('./envelope');
49
+ const { QueryBuilder } = require('../../../src/core/db/query-builder');
50
+
51
+ const DEFAULT_BATCH_SIZE = 1000;
52
+
53
+ /**
54
+ * Buat Error dengan exitCode terlampir (override default dispatcher).
55
+ * @param {string} message
56
+ * @param {number} exitCode
57
+ * @returns {Error}
58
+ */
59
+ function failWith(message, exitCode) {
60
+ const err = new Error(message);
61
+ err.exitCode = exitCode;
62
+ return err;
63
+ }
64
+
65
+ /**
66
+ * Petakan `config.dialect` internal ke bentuk DB_TYPE kanonik untuk envelope.
67
+ *
68
+ * `loadConfig` memetakan `DB_TYPE=postgresql` menjadi `dialect='postgres'`; envelope
69
+ * (header `source_dialect`) dan round-trip `data push` (Phase 03) memakai bentuk
70
+ * kanonik `postgresql`. Dialect `mysql`/`oracle`/`sqlite` identik di kedua sisi.
71
+ *
72
+ * @param {string} dialect - config.dialect ('postgres'|'mysql'|'oracle'|'sqlite')
73
+ * @returns {string} 'postgresql'|'mysql'|'oracle'|'sqlite'
74
+ */
75
+ function canonicalSourceDialect(dialect) {
76
+ return dialect === 'postgres' ? 'postgresql' : dialect;
77
+ }
78
+
79
+ /**
80
+ * Normalisasi nilai integer non-negatif dari flag.
81
+ * @param {*} value
82
+ * @param {number|null} fallback
83
+ * @returns {number|null}
84
+ */
85
+ function normalizeInt(value, fallback) {
86
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
87
+ return Math.trunc(value);
88
+ }
89
+ return fallback;
90
+ }
91
+
92
+ /**
93
+ * Encode satu row mentah menjadi representasi envelope, hanya kolom whitelist SDF
94
+ * dalam urutan deklarasi field.
95
+ * @param {Object} row
96
+ * @param {Array<{name: string, type: string}>} columns
97
+ * @returns {Object}
98
+ */
99
+ function encodeRow(row, columns) {
100
+ const out = {};
101
+ for (const col of columns) {
102
+ out[col.name] = encodeValue(row[col.name], col.type);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Baca rows tabel secara bertahap dan kirim tiap batch (sudah ter-encode) ke onBatch.
109
+ *
110
+ * - Keyset bila PK tunggal: ORDER BY pk, tiap batch WHERE pk > last LIMIT n.
111
+ * - OFFSET fallback bila PK composite: ORDER BY pk LIMIT n OFFSET m.
112
+ * - `limit` membatasi total rows; berhenti saat tercapai.
113
+ *
114
+ * @param {Object} opts
115
+ * @param {Object} opts.executor - db-executor (dbUtils untuk QueryBuilder)
116
+ * @param {Object} opts.dialect - instance dialect
117
+ * @param {string} opts.queryTable - nama tabel target (qualified bila ber-schema)
118
+ * @param {Array<{name: string, type: string}>} opts.columns - kolom whitelist SDF
119
+ * @param {string[]} opts.pk - daftar kolom primary key
120
+ * @param {number} opts.batchSize - ukuran batch
121
+ * @param {number|null} opts.limit - batas total rows
122
+ * @param {Function} opts.onBatch - callback(encodedRows) per batch
123
+ * @returns {Promise<number>} total rows yang di-pull
124
+ */
125
+ async function pullInBatches({ executor, dialect, queryTable, columns, pk, batchSize, limit, onBatch }) {
126
+ const columnNames = columns.map((c) => c.name);
127
+ const useKeyset = pk.length === 1;
128
+ // `run()` menjamin PK non-kosong (no-PK = hard error sebelum mencapai sini),
129
+ // sehingga ORDER BY selalu memakai kolom PK; tak ada fallback seluruh-kolom.
130
+ const orderCols = pk;
131
+
132
+ let total = 0;
133
+ let lastKey = null;
134
+ let offset = 0;
135
+
136
+ for (;;) {
137
+ if (limit !== null && total >= limit) break;
138
+
139
+ let perBatch = batchSize;
140
+ if (limit !== null) {
141
+ perBatch = Math.min(batchSize, limit - total);
142
+ }
143
+ if (perBatch <= 0) break;
144
+
145
+ const qb = new QueryBuilder(executor, dialect, queryTable);
146
+ qb.select(...columnNames);
147
+
148
+ if (useKeyset) {
149
+ if (lastKey !== null && lastKey !== undefined) {
150
+ qb.where(pk[0], '>', lastKey);
151
+ }
152
+ qb.orderBy(pk[0], 'asc');
153
+ qb.limit(perBatch);
154
+ } else {
155
+ for (const col of orderCols) qb.orderBy(col, 'asc');
156
+ qb.limit(perBatch);
157
+ qb.offset(offset);
158
+ }
159
+
160
+ const rows = await qb.get();
161
+ if (!rows || rows.length === 0) break;
162
+
163
+ if (useKeyset) {
164
+ lastKey = rows[rows.length - 1][pk[0]];
165
+ } else {
166
+ offset += rows.length;
167
+ }
168
+
169
+ onBatch(rows.map((row) => encodeRow(row, columns)));
170
+ total += rows.length;
171
+
172
+ if (rows.length < perBatch) break;
173
+ }
174
+
175
+ return total;
176
+ }
177
+
178
+ /**
179
+ * Pull satu tabel SDF ke file envelope JSON `<output>/<qualifiedName>.json`.
180
+ *
181
+ * Mengelola executor-nya sendiri (buat → disconnect, termasuk cleanup file partial
182
+ * saat error) agar isolasi per-tabel jelas dan jalur --all-tables dapat memanggilnya
183
+ * berulang tanpa kebocoran koneksi. Metadata kolom, namespace, dan PK murni dari IR.
184
+ *
185
+ * @param {Object} ir - IR model SDF satu tabel
186
+ * @param {Object} ctx - konteks resolusi sekali-pakai
187
+ * @param {Object} ctx.config - config DB hasil loadConfig
188
+ * @param {string} ctx.sourceDialect - dialect kanonik untuk header envelope
189
+ * @param {Object} ctx.args - argumen ter-parse (storage-path, force, limit, batch-size)
190
+ * @param {string} ctx.cwd - working directory
191
+ * @returns {Promise<{table: string, rows: number, file: string, source_dialect: string}>}
192
+ */
193
+ async function runOne(ir, ctx) {
194
+ const { config, sourceDialect, args, cwd } = ctx;
195
+
196
+ const columns = irToColumns(ir);
197
+ const { qualifiedName } = getNamespace(ir);
198
+ const rawPk = getPrimaryKey(ir);
199
+ const pk = Array.isArray(rawPk) ? rawPk : [];
200
+
201
+ // ── a. Cek file output (SEBELUM menulis/konek) ───────────────────────────────
202
+ // Nama file = qualifiedName SDF (bukan ir.tableName), agar dua tabel bernama sama
203
+ // di schema berbeda tidak collision (mis. `sales.order_item.json`). Tabel tanpa
204
+ // schema: qualifiedName === tableName → `visitors.json` (tak berubah). Titik pada
205
+ // qualifiedName dipertahankan (valid sebagai nama file). push (Phase 03) menurunkan
206
+ // nama file dengan aturan identik dari SDF.
207
+ const outputDir = path.resolve(cwd, args['storage-path']);
208
+ const outFile = path.join(outputDir, `${qualifiedName}.json`);
209
+ if (fs.existsSync(outFile) && args.force !== true) {
210
+ const rel = path.relative(cwd, outFile).split(path.sep).join('/');
211
+ throw failWith(
212
+ `Output file already exists: ${rel}. Use --force to overwrite.`,
213
+ 1
214
+ );
215
+ }
216
+
217
+ // ── b. Validasi identifier (anti-injection) + no-PK guard ────────────────────
218
+ const dialect = createDialect(config.dialect);
219
+ validateIdentifier(dialect, qualifiedName);
220
+ for (const col of columns) validateIdentifier(dialect, col.name);
221
+
222
+ // No-PK = anomali: schema-validator MEWAJIBKAN primary key, jadi IR yang lolos load
223
+ // SDF pasti punya PK. Bila tetap kosong (mis. IR diinjeksi), gagal jelas lebih aman
224
+ // daripada melanjutkan dengan urutan baris tak tentu.
225
+ if (pk.length === 0) {
226
+ throw failWith(`Table '${ir.tableName}' has no primary key in SDF (unexpected)`, 1);
227
+ }
228
+
229
+ const executor = createExecutor(config);
230
+
231
+ const batchSize = normalizeInt(args['batch-size'], DEFAULT_BATCH_SIZE) || DEFAULT_BATCH_SIZE;
232
+ const limit = normalizeInt(args.limit, null);
233
+
234
+ // ── c-d. SELECT bertahap + tulis envelope streaming (sinkron) ─────────────────
235
+ // Penulisan via fs.writeSync (bukan WriteStream) menjaga streaming — tiap chunk
236
+ // langsung ditulis ke OS tanpa menahan seluruh envelope di memory — sekaligus
237
+ // sinkron sehingga satu-satunya async di runOne() adalah query DB.
238
+ let rowCount = 0;
239
+ let fd = null;
240
+ try {
241
+ fs.mkdirSync(outputDir, { recursive: true });
242
+ fd = fs.openSync(outFile, 'w');
243
+
244
+ const writer = new EnvelopeWriter((chunk) => fs.writeSync(fd, chunk));
245
+ writer.writeHeader({ table: ir.tableName, sourceDialect, columns });
246
+
247
+ rowCount = await pullInBatches({
248
+ executor,
249
+ dialect,
250
+ queryTable: qualifiedName,
251
+ columns,
252
+ pk,
253
+ batchSize,
254
+ limit,
255
+ onBatch: (encodedRows) => writer.appendRows(encodedRows)
256
+ });
257
+
258
+ writer.end();
259
+ fs.closeSync(fd);
260
+ fd = null;
261
+ } catch (e) {
262
+ // Tutup fd SEBELUM unlink (Windows menolak unlink file yang masih terbuka).
263
+ if (fd !== null) {
264
+ try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
265
+ fd = null;
266
+ }
267
+ // Bersihkan file partial agar tidak meninggalkan envelope tak lengkap.
268
+ try { fs.unlinkSync(outFile); } catch (_e) { /* sudah tidak ada */ }
269
+ // exitCode yang sudah di-set (mis. dari QueryBuilder/validasi) dipertahankan;
270
+ // selain itu error lapisan DB (connect/select) dipetakan ke exit 3.
271
+ if (Number.isInteger(e.exitCode)) throw e;
272
+ throw failWith(`Database error during pull: ${e.message}`, 3);
273
+ } finally {
274
+ try { await executor.disconnect(); } catch (_e) { /* ignore */ }
275
+ }
276
+
277
+ // ── e. Ringkasan per-tabel (tanpa `command`; dirakit caller) ─────────────────
278
+ const relFile = path.relative(cwd, outFile).split(path.sep).join('/');
279
+ return {
280
+ table: ir.tableName,
281
+ rows: rowCount,
282
+ file: relFile,
283
+ source_dialect: sourceDialect
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Jalankan command `data pull` (dispatcher: single-table vs --all-tables).
289
+ * @param {Object} args - argumen ter-parse dari contract
290
+ * @returns {Promise<Object>} ringkasan single { command, table, rows, file, source_dialect }
291
+ * atau agregat { command, all_tables, table_count, total_rows, tables: [...] }
292
+ */
293
+ async function run(args) {
294
+ const cwd = process.cwd();
295
+
296
+ // ── 1. Resolve config DB (SEKALI) ────────────────────────────────────────────
297
+ const resolved = resolveConfig(args.config, cwd);
298
+ if (!resolved) {
299
+ process.stderr.write(
300
+ "Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs\n"
301
+ );
302
+ throw failWith('--config=<file> is required.', 2);
303
+ }
304
+ if (resolved.source === 'default') {
305
+ printDefaultConfigWarning(resolved.defaultName);
306
+ }
307
+
308
+ let config;
309
+ try {
310
+ config = loadConfig(resolved.path);
311
+ } catch (e) {
312
+ throw failWith(e.message, 2);
313
+ }
314
+
315
+ // ── 2. Validasi mutual-exclusive --table / --all-tables (SEBELUM SDF/DB) ──────
316
+ // Arg-parser tidak punya mekanisme XOR lintas-flag (hanya `required` per-flag),
317
+ // sehingga "tepat satu" ditegakkan di sini. Exit 2 (precondition/usage).
318
+ const hasTable = args.table != null && args.table !== '';
319
+ const hasAll = args['all-tables'] === true;
320
+ if (hasTable && hasAll) {
321
+ throw failWith('Specify exactly one of --table or --all-tables.', 2);
322
+ }
323
+ if (!hasTable && !hasAll) {
324
+ throw failWith('Specify either --table=<name> or --all-tables.', 2);
325
+ }
326
+
327
+ // ── 3. Validasi --format ─────────────────────────────────────────────────────
328
+ if (args.format !== 'json') {
329
+ throw failWith(
330
+ `Unsupported --format='${args.format}'. Only 'json' is supported in this version.`,
331
+ 2
332
+ );
333
+ }
334
+
335
+ // ── 4. Load SDF (SEKALI) ─────────────────────────────────────────────────────
336
+ const schemaPath = args['schema-path'];
337
+ const absSchemaPath = path.resolve(cwd, schemaPath);
338
+ let models;
339
+ try {
340
+ models = loadSdf(absSchemaPath);
341
+ } catch (e) {
342
+ // Error baca SDF (path tidak ada, parse gagal) = operational.
343
+ throw failWith(e.message, 1);
344
+ }
345
+
346
+ const ctx = {
347
+ config,
348
+ sourceDialect: canonicalSourceDialect(config.dialect),
349
+ args,
350
+ cwd
351
+ };
352
+
353
+ // ── 5a. Jalur --table (single; regresi nol) ──────────────────────────────────
354
+ if (hasTable) {
355
+ const ir = findTableOrThrow(models, args.table); // throw exitCode=2 bila tak terdaftar
356
+ const per = await runOne(ir, ctx);
357
+ const summary = { command: 'pull', ...per };
358
+
359
+ if (args.json === true) {
360
+ process.stdout.write(JSON.stringify(summary) + '\n');
361
+ } else {
362
+ process.stdout.write(
363
+ `Pulled ${per.rows} rows from ${per.table} -> ${per.file} (dialect: ${per.source_dialect})\n`
364
+ );
365
+ }
366
+ return summary;
367
+ }
368
+
369
+ // ── 5b. Jalur --all-tables (fail-fast Q3; agregat) ───────────────────────────
370
+ // Iterasi mengikuti urutan insertion Map (file SDF ter-sort alfabetis). Error
371
+ // pada satu tabel menghentikan loop (fail-fast); tabel yang sudah selesai
372
+ // sebelum error tetap tertulis (konsekuensi Q3/R4, didokumentasikan Phase 05).
373
+ const tables = [];
374
+ let totalRows = 0;
375
+ for (const ir of models.values()) {
376
+ const per = await runOne(ir, ctx);
377
+ tables.push(per);
378
+ totalRows += per.rows;
379
+ if (args.json !== true) {
380
+ process.stdout.write(
381
+ `Pulled ${per.rows} rows from ${per.table} -> ${per.file} (dialect: ${per.source_dialect})\n`
382
+ );
383
+ }
384
+ }
385
+
386
+ const aggregate = {
387
+ command: 'pull',
388
+ all_tables: true,
389
+ table_count: tables.length,
390
+ total_rows: totalRows,
391
+ tables
392
+ };
393
+
394
+ if (args.json === true) {
395
+ process.stdout.write(JSON.stringify(aggregate) + '\n');
396
+ } else {
397
+ process.stdout.write(`Done: ${tables.length} tables, ${totalRows} rows total\n`);
398
+ }
399
+
400
+ return aggregate;
401
+ }
402
+
403
+ module.exports = {
404
+ run,
405
+ // Diekspor untuk reuse/test internal.
406
+ _internal: { runOne, pullInBatches, encodeRow, normalizeInt, canonicalSourceDialect }
407
+ };