@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.
- 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 +950 -0
- package/generators/cli/payload/sync.js +18 -2
- 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/migrate/field-type-resolver.js +18 -5
- package/generators/lib/payload/payload-runner.js +724 -39
- 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,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 };
|