@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.
- package/build-info.json +2 -2
- package/cli/consumer-deploy.js +1 -1
- package/cli/consumer.js +1 -1
- package/generators/cli/data/pull.js +95 -0
- package/generators/cli/data/push.js +85 -0
- package/generators/cli/fast-track.js +12 -25
- package/generators/cli/schema/introspect.js +10 -10
- package/generators/lib/data/db-executor.js +440 -0
- package/generators/lib/data/dialect-kit.js +56 -0
- package/generators/lib/data/envelope.js +220 -0
- package/generators/lib/data/pull-runner.js +407 -0
- package/generators/lib/data/push-runner.js +382 -0
- package/generators/lib/data/sdf-reader.js +132 -0
- package/generators/lib/data/table-order.js +126 -0
- package/generators/lib/data/value-codec.js +188 -0
- package/generators/lib/templates/dashboard-catalog.js +1 -1
- package/generators/lib/templates/db-connection-env.js +1 -1
- package/generators/lib/templates/dbschema-catalog.js +1 -1
- package/generators/lib/templates/field-validation-catalog.js +1 -1
- package/generators/lib/templates/mysql-template.js +1 -1
- package/generators/lib/templates/oracle-template.js +1 -1
- package/generators/lib/templates/postgres-template.js +1 -1
- package/generators/lib/templates/query-declarative-catalog.js +1 -1
- package/generators/lib/templates/sqlite-template.js +1 -1
- package/integrity-manifest.json +18 -18
- package/package.json +1 -1
- package/scripts/verify-integrity.js +1 -1
- package/server.js +1 -1
- package/src/components/handlers/adjust_handler.js +1 -1
- package/src/components/handlers/audit_handler.js +1 -1
- package/src/components/handlers/delete_handler.js +1 -1
- package/src/components/handlers/export_handler.js +1 -1
- package/src/components/handlers/import_handler.js +1 -1
- package/src/components/handlers/insert_handler.js +1 -1
- package/src/components/handlers/update_handler.js +1 -1
- package/src/components/handlers/upload_handler.js +1 -1
- package/src/components/handlers/workflow_handler.js +1 -1
- package/src/components/integrations/webhook.js +1 -1
- package/src/consumers/baseConsumer.js +1 -1
- package/src/consumers/declarativeMapper.js +1 -1
- package/src/consumers/handlers/apiHandler.js +1 -1
- package/src/consumers/handlers/consoleHandler.js +1 -1
- package/src/consumers/handlers/databaseHandler.js +1 -1
- package/src/consumers/handlers/index.js +1 -1
- package/src/consumers/handlers/kafkaHandler.js +1 -1
- package/src/consumers/index.js +1 -1
- package/src/consumers/messageTransformer.js +1 -1
- package/src/consumers/validator.js +1 -1
- package/src/core/db/dialect/base-dialect.js +1 -1
- package/src/core/db/dialect/index.js +1 -1
- package/src/core/db/dialect/mysql-dialect.js +1 -1
- package/src/core/db/dialect/oracle-dialect.js +1 -1
- package/src/core/db/dialect/postgres-dialect.js +1 -1
- package/src/core/db/dialect/sqlite-dialect.js +1 -1
- package/src/core/db/flatten-helper.js +1 -1
- package/src/core/db/query-builder-error.js +1 -1
- package/src/core/db/query-builder.js +1 -1
- package/src/core/db/relation-helper.js +1 -1
- package/src/core/handlers/delete_handler.js +1 -1
- package/src/core/handlers/insert_handler.js +1 -1
- package/src/core/handlers/update_handler.js +1 -1
- package/src/core/models/base-model.js +1 -1
- package/src/core/utils/cache-manager.js +1 -1
- package/src/core/utils/component-engine.js +1 -1
- package/src/core/utils/context-builder.js +1 -1
- package/src/core/utils/datetime-formatter.js +1 -1
- package/src/core/utils/datetime-parser.js +1 -1
- package/src/core/utils/db.js +1 -1
- package/src/core/utils/logger.js +1 -1
- package/src/core/utils/payload-loader.js +1 -1
- package/src/core/utils/security-checks.js +1 -1
- package/src/middleware/body-options.js +1 -1
- package/src/middleware/cors.js +1 -1
- package/src/middleware/idempotency.js +1 -1
- package/src/middleware/rate-limiter.js +1 -1
- package/src/middleware/request-logger.js +1 -1
- package/src/middleware/security-headers.js +1 -1
- package/src/models/base-model-mysql.js +1 -1
- package/src/models/base-model-oracle.js +1 -1
- package/src/models/base-model-sqlite.js +1 -1
- package/src/models/base-model.js +1 -1
- package/src/pro/caching/redis-client.js +1 -1
- package/src/pro/caching/redis-helper.js +1 -1
- package/src/pro/consumers/baseConsumer.js +1 -1
- package/src/pro/consumers/declarativeMapper.js +1 -1
- package/src/pro/consumers/handlers/apiHandler.js +1 -1
- package/src/pro/consumers/handlers/consoleHandler.js +1 -1
- package/src/pro/consumers/handlers/databaseHandler.js +1 -1
- package/src/pro/consumers/handlers/index.js +1 -1
- package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
- package/src/pro/consumers/index.js +1 -1
- package/src/pro/consumers/messageTransformer.js +1 -1
- package/src/pro/consumers/validator.js +1 -1
- package/src/pro/database/base-model-mysql.js +1 -1
- package/src/pro/database/base-model-oracle.js +1 -1
- package/src/pro/database/base-model-sqlite.js +1 -1
- package/src/pro/database/db-mysql.js +1 -1
- package/src/pro/database/db-oracle.js +1 -1
- package/src/pro/database/db-sqlite.js +1 -1
- package/src/pro/excel/excel-generator.js +1 -1
- package/src/pro/excel/excel-parser.js +1 -1
- package/src/pro/excel/export-service.js +1 -1
- package/src/pro/excel/export_handler.js +1 -1
- package/src/pro/excel/import-service.js +1 -1
- package/src/pro/excel/import-validator.js +1 -1
- package/src/pro/excel/import_handler.js +1 -1
- package/src/pro/excel/upsert-builder.js +1 -1
- package/src/pro/idgen/idgen-routes.js +1 -1
- package/src/pro/integrations/lookup-resolver.js +1 -1
- package/src/pro/integrations/upload-handler-v2.js +1 -1
- package/src/pro/integrations/upload-handler.js +1 -1
- package/src/pro/integrations/webhook.js +1 -1
- package/src/pro/locking/lock-routes.js +1 -1
- package/src/pro/locking/resource-lock-manager.js +1 -1
- package/src/pro/messaging/kafkaConsumerService.js +1 -1
- package/src/pro/messaging/kafkaService.js +1 -1
- package/src/pro/messaging/messagehubService.js +1 -1
- package/src/pro/messaging/rabbitmqService.js +1 -1
- package/src/pro/scheduler/job-manager.js +1 -1
- package/src/pro/scheduler/job-routes.js +1 -1
- package/src/pro/scheduler/job-validator.js +1 -1
- package/src/pro/storage/base-storage-provider.js +1 -1
- package/src/pro/storage/file-metadata-helper.js +1 -1
- package/src/pro/storage/index.js +1 -1
- package/src/pro/storage/local-storage-provider.js +1 -1
- package/src/pro/storage/s3-storage-provider.js +1 -1
- package/src/pro/storage/upload-cleanup-job.js +1 -1
- package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
- package/src/pro/storage/upload-pending-tracker.js +1 -1
- package/src/pro/websocket/broadcast-helper.js +1 -1
- package/src/pro/websocket/index.js +1 -1
- package/src/pro/websocket/livesync-server.js +1 -1
- package/src/pro/websocket/ws-broadcaster.js +1 -1
- package/src/services/export-service.js +1 -1
- package/src/services/import-service.js +1 -1
- package/src/services/kafkaConsumerService.js +1 -1
- package/src/services/kafkaService.js +1 -1
- package/src/services/messagehubService.js +1 -1
- package/src/services/rabbitmqService.js +1 -1
- package/src/utils/cache-invalidation-registry.js +1 -1
- package/src/utils/cache-manager.js +1 -1
- package/src/utils/component-engine.js +1 -1
- package/src/utils/config-extractor.js +1 -1
- package/src/utils/consumerLogger.js +1 -1
- package/src/utils/context-builder.js +1 -1
- package/src/utils/dashboard-helpers.js +1 -1
- package/src/utils/dateHelper.js +1 -1
- package/src/utils/datetime-formatter.js +1 -1
- package/src/utils/datetime-parser.js +1 -1
- package/src/utils/db-bootstrap.js +1 -1
- package/src/utils/db-mysql.js +1 -1
- package/src/utils/db-oracle.js +1 -1
- package/src/utils/db-sqlite.js +1 -1
- package/src/utils/db.js +1 -1
- package/src/utils/demo-generator.js +1 -1
- package/src/utils/excel-generator.js +1 -1
- package/src/utils/excel-parser.js +1 -1
- package/src/utils/file-watcher.js +1 -1
- package/src/utils/id-generator.js +1 -1
- package/src/utils/idempotency-manager.js +1 -1
- package/src/utils/import-validator.js +1 -1
- package/src/utils/license-client.js +1 -1
- package/src/utils/lock-manager.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/lookup-resolver.js +1 -1
- package/src/utils/payload-loader.js +1 -1
- package/src/utils/processor-response.js +1 -1
- package/src/utils/rabbitmq.js +1 -1
- package/src/utils/redis-client.js +1 -1
- package/src/utils/redis-helper.js +1 -1
- package/src/utils/request-scope.js +1 -1
- package/src/utils/security-checks.js +1 -1
- package/src/utils/service-resolver.js +1 -1
- package/src/utils/shutdown-coordinator.js +1 -1
- package/src/utils/trusted-keys.js +1 -1
- package/src/utils/upload-handler.js +1 -1
- package/src/utils/upsert-builder.js +1 -1
- 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
|
+
};
|