@restforgejs/platform 4.1.1 → 4.2.8
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/bin/sdf-tools.exe +0 -0
- package/build-info.json +2 -2
- package/cli/consumer-deploy.js +1 -1
- package/cli/consumer.js +1 -1
- package/generators/cli/endpoint/create.js +42 -3
- package/generators/cli/schema/apply.js +525 -0
- package/generators/cli/schema/diff.js +321 -0
- package/generators/cli/schema/generate-ddl.js +7 -10
- package/generators/cli/schema/init.js +95 -172
- package/generators/cli/schema/migrate.js +10 -16
- package/generators/cli/schema/models.js +8 -12
- package/generators/cli/schema/template.js +222 -0
- package/generators/cli/schema/validate.js +8 -12
- package/generators/cli-entry.js +17 -2
- package/generators/lib/dbschema-kit/apply-engine.js +582 -0
- package/generators/lib/dbschema-kit/diff-engine.js +703 -0
- package/generators/lib/dbschema-kit/diff-reporter.js +272 -0
- package/generators/lib/dbschema-kit/emitters/alter-table.js +275 -0
- package/generators/lib/payload/endpoint-schema-validator.js +171 -0
- package/generators/lib/payload/payload-runner.js +137 -220
- package/generators/lib/payload/schema-diff.js +277 -0
- package/generators/lib/utils/audit-columns.js +181 -0
- package/generators/lib/utils/cli-output.js +17 -0
- package/generators/lib/utils/database-introspector.js +16 -13
- package/integrity-manifest.json +8 -8
- 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
|
@@ -33,6 +33,7 @@ const MetadataManager = require('../../lib/utils/metadata-manager');
|
|
|
33
33
|
const DemoGenerator = require('../../src/utils/demo-generator');
|
|
34
34
|
const projectRegistry = require('../../lib/utils/project-registry');
|
|
35
35
|
const cliOutput = require('../../lib/utils/cli-output');
|
|
36
|
+
const endpointSchemaValidator = require('../../lib/payload/endpoint-schema-validator');
|
|
36
37
|
|
|
37
38
|
function hasAuditRequired(payload) {
|
|
38
39
|
if (!payload || !payload.fieldPolicy) return false;
|
|
@@ -91,6 +92,18 @@ module.exports = {
|
|
|
91
92
|
default: null,
|
|
92
93
|
description: 'Tipe database (postgres|mysql|oracle|sqlite). Default: auto-detect dari config'
|
|
93
94
|
},
|
|
95
|
+
config: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
required: false,
|
|
98
|
+
default: null,
|
|
99
|
+
description: 'File config database (.env) untuk validasi schema payload-vs-database. Wajib kecuali `--skip-schema-check` aktif atau default config sudah di-set via `config set-default`. Fallback ke `.restforge/defaults.json` bila tidak disediakan eksplisit'
|
|
100
|
+
},
|
|
101
|
+
'skip-schema-check': {
|
|
102
|
+
type: 'boolean',
|
|
103
|
+
required: false,
|
|
104
|
+
default: false,
|
|
105
|
+
description: 'Lewati validasi schema database (escape hatch untuk DB offline atau maintenance). Shape RDF tetap divalidasi'
|
|
106
|
+
},
|
|
94
107
|
force: {
|
|
95
108
|
type: 'boolean',
|
|
96
109
|
required: false,
|
|
@@ -123,8 +136,9 @@ module.exports = {
|
|
|
123
136
|
}
|
|
124
137
|
},
|
|
125
138
|
examples: [
|
|
126
|
-
'npx restforge endpoint create --project=my-app --name=users --payload=users.json',
|
|
127
|
-
'npx restforge endpoint create --project=my-app --name=orders --payload=orders.json --force'
|
|
139
|
+
'npx restforge endpoint create --project=my-app --name=users --payload=users.json --config=db.env',
|
|
140
|
+
'npx restforge endpoint create --project=my-app --name=orders --payload=orders.json --config=db.env --force=true',
|
|
141
|
+
'npx restforge endpoint create --project=my-app --name=visitors --payload=visitors.json --skip-schema-check'
|
|
128
142
|
],
|
|
129
143
|
async handler(args) {
|
|
130
144
|
const startTime = Date.now();
|
|
@@ -142,6 +156,10 @@ module.exports = {
|
|
|
142
156
|
const skipSqlValidation = !!args['skip-sql-validation'];
|
|
143
157
|
const noAuditMigration = !!args['no-audit-migration'];
|
|
144
158
|
const verbose = !!args.verbose;
|
|
159
|
+
const skipSchemaCheck = !!args['skip-schema-check'];
|
|
160
|
+
const configArg = typeof args.config === 'string' && args.config.trim().length > 0
|
|
161
|
+
? args.config.trim()
|
|
162
|
+
: null;
|
|
145
163
|
|
|
146
164
|
if (!verbose) {
|
|
147
165
|
cliOutput.mute();
|
|
@@ -178,6 +196,26 @@ module.exports = {
|
|
|
178
196
|
warnings: cliOutput.drainWarnings()
|
|
179
197
|
};
|
|
180
198
|
|
|
199
|
+
// Schema validation pre-codegen: cross-check payload terhadap struktur
|
|
200
|
+
// tabel database aktual. Wajib unless --skip-schema-check di-set.
|
|
201
|
+
// Drift -> throw (exitCode=1). Connection fail -> throw (exitCode=3).
|
|
202
|
+
// Validasi dilakukan SEBELUM conflict-checker + archive supaya gagal
|
|
203
|
+
// tanpa rotate file existing.
|
|
204
|
+
if (muted) cliOutput.unmute();
|
|
205
|
+
const schemaResult = await endpointSchemaValidator.validateEndpointSchema({
|
|
206
|
+
payload,
|
|
207
|
+
payloadFileName: path.basename(payloadFile),
|
|
208
|
+
configArg,
|
|
209
|
+
skipSchemaCheck,
|
|
210
|
+
workingDir: cwd
|
|
211
|
+
});
|
|
212
|
+
if (!verbose) {
|
|
213
|
+
cliOutput.mute();
|
|
214
|
+
muted = true;
|
|
215
|
+
}
|
|
216
|
+
summary.schemaValidation = schemaResult;
|
|
217
|
+
summary.config.config = configArg || null;
|
|
218
|
+
|
|
181
219
|
const registry = projectRegistry.loadProjectRegistry();
|
|
182
220
|
if (registry.projects[project]) {
|
|
183
221
|
const existing = registry.projects[project];
|
|
@@ -271,8 +309,9 @@ module.exports = {
|
|
|
271
309
|
summary.duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
272
310
|
cliOutput.printCreateSummary(summary);
|
|
273
311
|
} catch (error) {
|
|
312
|
+
// cli-entry.js men-print `Error: <message>` ke stderr saat handler
|
|
313
|
+
// re-throw (lihat handler dispatch). Jangan double-print di sini.
|
|
274
314
|
if (muted) cliOutput.unmute();
|
|
275
|
-
console.error(`Error: ${error.message}`);
|
|
276
315
|
throw error;
|
|
277
316
|
}
|
|
278
317
|
}
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract: schema apply
|
|
5
|
+
*
|
|
6
|
+
* Apply drift dari SDF ke database via ALTER TABLE incremental.
|
|
7
|
+
* Komplemen schema diff (detect only) dan schema migrate (full create/drop).
|
|
8
|
+
*
|
|
9
|
+
* Default: additive only (ADD COLUMN/INDEX/UNIQUE). Drift onlyInDb atau
|
|
10
|
+
* mismatched di-skip dengan warning kecuali opt-in flag aktif.
|
|
11
|
+
*
|
|
12
|
+
* Exit code:
|
|
13
|
+
* 0 - semua drift applicable ter-apply (atau no drift)
|
|
14
|
+
* 1 - ada drift yang di-skip karena butuh opt-in (--allow-drop / --allow-modify)
|
|
15
|
+
* 2 - error
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const { loadSchemaPath } = require('../../lib/dbschema-kit/loader');
|
|
23
|
+
const { mapTableMetaToIR } = require('../../lib/dbschema-kit/introspect-mapper');
|
|
24
|
+
const { loadConfig } = require('../../lib/dbschema-kit/connection');
|
|
25
|
+
const { diffModels } = require('../../lib/dbschema-kit/diff-engine');
|
|
26
|
+
const { generateAlterStatements } = require('../../lib/dbschema-kit/apply-engine');
|
|
27
|
+
const { resolveConfig, printDefaultConfigWarning } = require('../../lib/utils/config-resolver');
|
|
28
|
+
|
|
29
|
+
function loadIntrospector() {
|
|
30
|
+
const stubPath = process.env.DBSCHEMA_KIT_TEST_INTROSPECT_STUB;
|
|
31
|
+
if (stubPath) {
|
|
32
|
+
return require(path.resolve(stubPath));
|
|
33
|
+
}
|
|
34
|
+
return defaultIntrospector;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadApplyExecutor() {
|
|
38
|
+
const stubPath = process.env.DBSCHEMA_KIT_TEST_APPLY_STUB;
|
|
39
|
+
if (stubPath) {
|
|
40
|
+
return require(path.resolve(stubPath));
|
|
41
|
+
}
|
|
42
|
+
return require('../../lib/dbschema-kit/apply-executor');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mapDialectToDbType(dialect) {
|
|
46
|
+
switch (dialect) {
|
|
47
|
+
case 'postgres': return 'postgresql';
|
|
48
|
+
case 'mysql': return 'mysql';
|
|
49
|
+
case 'oracle': return 'oracle';
|
|
50
|
+
case 'sqlite': return 'sqlite';
|
|
51
|
+
default: return dialect;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildEphemeralEnvFile(config, dbType) {
|
|
56
|
+
const lines = [`DB_TYPE=${dbType}`];
|
|
57
|
+
if (config.host !== undefined) lines.push(`DB_HOST=${config.host}`);
|
|
58
|
+
if (config.port !== undefined) lines.push(`DB_PORT=${config.port}`);
|
|
59
|
+
if (dbType === 'oracle') {
|
|
60
|
+
lines.push(`DB_NAME=${config.serviceName || ''}`);
|
|
61
|
+
} else if (config.database !== undefined) {
|
|
62
|
+
lines.push(`DB_NAME=${config.database}`);
|
|
63
|
+
}
|
|
64
|
+
if (config.user !== undefined) lines.push(`DB_USER=${config.user}`);
|
|
65
|
+
if (config.password !== undefined) lines.push(`DB_PASSWORD=${config.password}`);
|
|
66
|
+
return lines.join('\n') + '\n';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createTmpEnvFile(content) {
|
|
70
|
+
const tmpDir = os.tmpdir();
|
|
71
|
+
const tmpName = `restforge-apply-${process.pid}-${Date.now()}.env`;
|
|
72
|
+
const fullPath = path.join(tmpDir, tmpName);
|
|
73
|
+
fs.writeFileSync(fullPath, content, 'utf8');
|
|
74
|
+
return fullPath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function collectTableMeta(introspector, target) {
|
|
78
|
+
const ref = target.schemaName ? `${target.schemaName}.${target.tableName}` : target.tableName;
|
|
79
|
+
const detailedColumns = await introspector.getDetailedColumnInfo(ref);
|
|
80
|
+
const constraints = await introspector.getConstraints(ref);
|
|
81
|
+
const foreignKeys = await introspector.getForeignKeys(ref);
|
|
82
|
+
const indexes = await introspector.getIndexes(ref);
|
|
83
|
+
|
|
84
|
+
const pkConstraint = constraints.find((c) => c.type === 'PRIMARY KEY');
|
|
85
|
+
const uniqueConstraints = constraints
|
|
86
|
+
.filter((c) => c.type === 'UNIQUE')
|
|
87
|
+
.map((c) => ({ name: c.name, columns: c.columns }));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
tableName: target.tableName,
|
|
91
|
+
schemaName: target.schemaName || null,
|
|
92
|
+
columns: detailedColumns,
|
|
93
|
+
primaryKey: pkConstraint ? pkConstraint.columns : [],
|
|
94
|
+
uniques: uniqueConstraints,
|
|
95
|
+
foreignKeys,
|
|
96
|
+
indexes,
|
|
97
|
+
checks: []
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const defaultIntrospector = {
|
|
102
|
+
introspect: async ({ config, tables }) => {
|
|
103
|
+
const dbType = mapDialectToDbType(config.dialect);
|
|
104
|
+
if (dbType === 'sqlite') {
|
|
105
|
+
throw new Error('schema:apply does not yet support sqlite. Use postgres, mysql, or oracle.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { DatabaseIntrospector } = require('../../lib/utils/database-introspector');
|
|
109
|
+
const introspector = new DatabaseIntrospector({ quiet: true });
|
|
110
|
+
|
|
111
|
+
const ephemeralEnv = buildEphemeralEnvFile(config, dbType);
|
|
112
|
+
const tmpPath = createTmpEnvFile(ephemeralEnv);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const ok = introspector.loadConfig(tmpPath);
|
|
116
|
+
if (!ok) throw new Error('Failed to load config into DatabaseIntrospector');
|
|
117
|
+
const connected = await introspector.connect();
|
|
118
|
+
if (!connected) throw new Error('Failed to connect to database');
|
|
119
|
+
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const target of tables) {
|
|
122
|
+
try {
|
|
123
|
+
out.push(await collectTableMeta(introspector, target));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
out.push({
|
|
126
|
+
tableName: target.tableName,
|
|
127
|
+
schemaName: target.schemaName || null,
|
|
128
|
+
columns: [],
|
|
129
|
+
primaryKey: [],
|
|
130
|
+
uniques: [],
|
|
131
|
+
foreignKeys: [],
|
|
132
|
+
indexes: [],
|
|
133
|
+
checks: [],
|
|
134
|
+
_missing: true,
|
|
135
|
+
_error: err && err.message ? err.message : String(err)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { tables: out };
|
|
140
|
+
} finally {
|
|
141
|
+
try { await introspector.disconnect(); } catch (e) { /* ignore */ }
|
|
142
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function buildConnectionSummary(config) {
|
|
148
|
+
if (config.dialect === 'sqlite') {
|
|
149
|
+
return `sqlite @ ${config.file}`;
|
|
150
|
+
}
|
|
151
|
+
if (config.dialect === 'oracle') {
|
|
152
|
+
return `oracle @ ${config.host}:${config.port}/${config.serviceName}`;
|
|
153
|
+
}
|
|
154
|
+
return `${config.dialect} @ ${config.host}:${config.port}/${config.database}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatStatementForDisplay(stmt, dialect) {
|
|
158
|
+
if (dialect === 'oracle' && /^\s*BEGIN\b/i.test(stmt)) {
|
|
159
|
+
return stmt + '\n/';
|
|
160
|
+
}
|
|
161
|
+
return stmt + ';';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function printDryRun(result, dialect, outStream) {
|
|
165
|
+
const out = outStream || process.stdout;
|
|
166
|
+
out.write('-- DDL Preview (schema apply, dry-run) --\n');
|
|
167
|
+
out.write('\n');
|
|
168
|
+
|
|
169
|
+
const tables = Object.keys(result.statementsByTable);
|
|
170
|
+
for (const table of tables) {
|
|
171
|
+
out.write(`[${table}]\n`);
|
|
172
|
+
for (const stmt of result.statementsByTable[table]) {
|
|
173
|
+
out.write(formatStatementForDisplay(stmt, dialect));
|
|
174
|
+
out.write('\n');
|
|
175
|
+
}
|
|
176
|
+
out.write('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (tables.length === 0) {
|
|
180
|
+
out.write('No applicable ALTER statements.\n');
|
|
181
|
+
out.write('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (result.skipped.length > 0) {
|
|
185
|
+
out.write('Warnings:\n');
|
|
186
|
+
for (const sk of result.skipped) {
|
|
187
|
+
out.write(` ${sk.description} (${sk.reason})\n`);
|
|
188
|
+
}
|
|
189
|
+
out.write('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
out.write('Summary:\n');
|
|
193
|
+
out.write(` Tables affected: ${result.summary.tablesAffected}\n`);
|
|
194
|
+
out.write(` Additions: ${result.summary.totalAdditions}\n`);
|
|
195
|
+
out.write(` Modifications: ${result.summary.totalModifications}\n`);
|
|
196
|
+
out.write(` Deletions: ${result.summary.totalDeletions}\n`);
|
|
197
|
+
out.write(` Skipped (opt-in): ${result.summary.totalSkipped}\n`);
|
|
198
|
+
out.write('\n');
|
|
199
|
+
out.write('Dry-run complete. No changes applied.\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildExitCode(result) {
|
|
203
|
+
const hasOptInSkip = result.skipped.some((s) =>
|
|
204
|
+
s.reason === 'requires --allow-drop' || s.reason === 'requires --allow-modify'
|
|
205
|
+
);
|
|
206
|
+
return hasOptInSkip ? 1 : 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
resource: 'schema',
|
|
211
|
+
verb: 'apply',
|
|
212
|
+
description: 'Apply drift dari SDF ke database via ALTER TABLE incremental (komplemen schema diff)',
|
|
213
|
+
category: 'management',
|
|
214
|
+
flags: {
|
|
215
|
+
path: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
required: true,
|
|
218
|
+
description: 'Path file atau folder schema (mis. ./schema atau ./schema/users.js)'
|
|
219
|
+
},
|
|
220
|
+
config: {
|
|
221
|
+
type: 'string',
|
|
222
|
+
required: true,
|
|
223
|
+
description: 'File config database (.env)'
|
|
224
|
+
},
|
|
225
|
+
table: {
|
|
226
|
+
type: 'string',
|
|
227
|
+
required: false,
|
|
228
|
+
default: null,
|
|
229
|
+
description: 'Apply hanya satu tabel spesifik (default: semua model di SDF)'
|
|
230
|
+
},
|
|
231
|
+
'dry-run': {
|
|
232
|
+
type: 'boolean',
|
|
233
|
+
required: false,
|
|
234
|
+
default: false,
|
|
235
|
+
description: 'Preview ALTER tanpa apply ke database'
|
|
236
|
+
},
|
|
237
|
+
'allow-drop': {
|
|
238
|
+
type: 'boolean',
|
|
239
|
+
required: false,
|
|
240
|
+
default: false,
|
|
241
|
+
description: 'Opt-in: izinkan DROP COLUMN/INDEX/UNIQUE constraint (destruktif data)'
|
|
242
|
+
},
|
|
243
|
+
'allow-modify': {
|
|
244
|
+
type: 'boolean',
|
|
245
|
+
required: false,
|
|
246
|
+
default: false,
|
|
247
|
+
description: 'Opt-in: izinkan ALTER COLUMN length/nullable (potential data loss)'
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
examples: [
|
|
251
|
+
'npx restforge schema apply --path=./schema --config=db.env --dry-run',
|
|
252
|
+
'npx restforge schema apply --path=./schema --config=db.env',
|
|
253
|
+
'npx restforge schema apply --path=./schema --config=db.env --allow-drop',
|
|
254
|
+
'npx restforge schema apply --path=./schema/visitors.js --config=db.env --table=visitors --dry-run'
|
|
255
|
+
],
|
|
256
|
+
async handler(args) {
|
|
257
|
+
// 1. Resolve config
|
|
258
|
+
const resolvedConfigResult = resolveConfig(args.config, process.cwd());
|
|
259
|
+
if (!resolvedConfigResult) {
|
|
260
|
+
console.error('Error: --config=<file> is required.');
|
|
261
|
+
console.error("Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs");
|
|
262
|
+
const err = new Error('--config=<file> is required');
|
|
263
|
+
err.exitCode = 2;
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
if (resolvedConfigResult.source === 'default') {
|
|
267
|
+
printDefaultConfigWarning(resolvedConfigResult.defaultName);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let config;
|
|
271
|
+
try {
|
|
272
|
+
config = loadConfig(resolvedConfigResult.path);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(`Error: ${err.message}`);
|
|
275
|
+
const e = new Error(err.message);
|
|
276
|
+
e.exitCode = 2;
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2. Load SDF
|
|
281
|
+
const schemaPath = args.path;
|
|
282
|
+
const absPath = path.resolve(process.cwd(), schemaPath);
|
|
283
|
+
|
|
284
|
+
let sdfModels;
|
|
285
|
+
try {
|
|
286
|
+
sdfModels = loadSchemaPath(absPath);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error(`Error: ${err.message}`);
|
|
289
|
+
const e = new Error(err.message);
|
|
290
|
+
e.exitCode = 2;
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (sdfModels.size === 0) {
|
|
295
|
+
console.error(`Error: No schema models found at '${schemaPath}'.`);
|
|
296
|
+
const e = new Error(`No schema models found at '${schemaPath}'`);
|
|
297
|
+
e.exitCode = 2;
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 3. Filter target tables
|
|
302
|
+
let targetEntries = Array.from(sdfModels.entries());
|
|
303
|
+
if (args.table) {
|
|
304
|
+
targetEntries = targetEntries.filter(([qualified, ir]) => {
|
|
305
|
+
return ir.tableName === args.table || qualified === args.table;
|
|
306
|
+
});
|
|
307
|
+
if (targetEntries.length === 0) {
|
|
308
|
+
console.error(`Error: Table '${args.table}' not found in SDF.`);
|
|
309
|
+
const e = new Error(`Table '${args.table}' not found in SDF`);
|
|
310
|
+
e.exitCode = 2;
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const targetsForDb = targetEntries.map(([_, ir]) => ({
|
|
316
|
+
tableName: ir.tableName,
|
|
317
|
+
schemaName: ir.schemaName || null
|
|
318
|
+
}));
|
|
319
|
+
|
|
320
|
+
// 4. Introspect database
|
|
321
|
+
let introspector;
|
|
322
|
+
try {
|
|
323
|
+
introspector = loadIntrospector();
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.error(`Error: Failed to load introspector: ${err.message}`);
|
|
326
|
+
const e = new Error(err.message);
|
|
327
|
+
e.exitCode = 2;
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let introspectResult;
|
|
332
|
+
try {
|
|
333
|
+
introspectResult = await introspector.introspect({ config, tables: targetsForDb });
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const message = err && err.message ? err.message : String(err);
|
|
336
|
+
console.error(`Error: ${message}`);
|
|
337
|
+
const e = new Error(message);
|
|
338
|
+
e.exitCode = 2;
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!introspectResult || !Array.isArray(introspectResult.tables)) {
|
|
343
|
+
console.error('Error: Introspector did not return a tables array.');
|
|
344
|
+
const e = new Error('Introspector did not return a tables array');
|
|
345
|
+
e.exitCode = 2;
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 5. Map DB meta to IR
|
|
350
|
+
const dialect = config.dialect;
|
|
351
|
+
const dbModels = new Map();
|
|
352
|
+
for (const tableMeta of introspectResult.tables) {
|
|
353
|
+
if (tableMeta._missing) {
|
|
354
|
+
const qualified = tableMeta.schemaName
|
|
355
|
+
? `${tableMeta.schemaName}.${tableMeta.tableName}`
|
|
356
|
+
: tableMeta.tableName;
|
|
357
|
+
dbModels.set(qualified, {
|
|
358
|
+
tableName: tableMeta.tableName,
|
|
359
|
+
schemaName: tableMeta.schemaName || null,
|
|
360
|
+
qualifiedName: qualified,
|
|
361
|
+
fields: {},
|
|
362
|
+
primaryKey: [],
|
|
363
|
+
indexes: [],
|
|
364
|
+
uniques: [],
|
|
365
|
+
relations: {},
|
|
366
|
+
checks: []
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const ir = mapTableMetaToIR(tableMeta, dialect);
|
|
371
|
+
dbModels.set(ir.qualifiedName || ir.tableName, ir);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 6. Build SDF map matching key convention
|
|
375
|
+
const sdfMap = new Map();
|
|
376
|
+
for (const [_, ir] of targetEntries) {
|
|
377
|
+
sdfMap.set(ir.qualifiedName || ir.tableName, ir);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 7. Compute deltas via diff-engine
|
|
381
|
+
const deltas = diffModels(sdfMap, dbModels);
|
|
382
|
+
|
|
383
|
+
// 8. Generate ALTER statements
|
|
384
|
+
let alterResult;
|
|
385
|
+
try {
|
|
386
|
+
alterResult = generateAlterStatements(deltas, {
|
|
387
|
+
dialect,
|
|
388
|
+
allowDrop: args['allow-drop'] === true,
|
|
389
|
+
allowModify: args['allow-modify'] === true,
|
|
390
|
+
sdfModels: sdfMap
|
|
391
|
+
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error(`Error: ${err.message}`);
|
|
394
|
+
const e = new Error(err.message);
|
|
395
|
+
e.exitCode = 2;
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 9. Dry-run path
|
|
400
|
+
if (args['dry-run']) {
|
|
401
|
+
printDryRun(alterResult, dialect);
|
|
402
|
+
const exitCode = buildExitCode(alterResult);
|
|
403
|
+
process.stdout.write(`Exit code: ${exitCode}\n`);
|
|
404
|
+
if (exitCode !== 0) {
|
|
405
|
+
const err = new Error(`schema apply dry-run: ${alterResult.summary.totalSkipped} drift entries require opt-in`);
|
|
406
|
+
err.exitCode = exitCode;
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 10. No-op path
|
|
413
|
+
if (alterResult.statements.length === 0) {
|
|
414
|
+
const exitCode = buildExitCode(alterResult);
|
|
415
|
+
if (alterResult.skipped.length === 0) {
|
|
416
|
+
console.log('No drift to apply.');
|
|
417
|
+
} else {
|
|
418
|
+
console.log('No applicable ALTER statements.');
|
|
419
|
+
console.log('Warnings:');
|
|
420
|
+
for (const sk of alterResult.skipped) {
|
|
421
|
+
console.log(` ${sk.description} (${sk.reason})`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (exitCode !== 0) {
|
|
425
|
+
const err = new Error(`schema apply: ${alterResult.summary.totalSkipped} drift entries require opt-in`);
|
|
426
|
+
err.exitCode = exitCode;
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 11. Apply path
|
|
433
|
+
console.log(`Connecting to database (${buildConnectionSummary(config)})...`);
|
|
434
|
+
console.log(`Applying ${alterResult.statements.length} ALTER statement(s)...`);
|
|
435
|
+
console.log('');
|
|
436
|
+
|
|
437
|
+
const tables = Object.keys(alterResult.statementsByTable);
|
|
438
|
+
const tableHeaderPrinted = new Set();
|
|
439
|
+
|
|
440
|
+
function progressHandler({ index, total, statement, status, error }) {
|
|
441
|
+
if (status === 'running') return;
|
|
442
|
+
const entry = alterResult.statements[index - 1];
|
|
443
|
+
const tableLabel = entry ? entry.table : '?';
|
|
444
|
+
if (!tableHeaderPrinted.has(tableLabel)) {
|
|
445
|
+
console.log(`[${tableLabel}]`);
|
|
446
|
+
tableHeaderPrinted.add(tableLabel);
|
|
447
|
+
}
|
|
448
|
+
if (status === 'success') {
|
|
449
|
+
console.log(` ✓ ${statement};`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (status === 'error') {
|
|
453
|
+
console.error(` ✗ ${statement};`);
|
|
454
|
+
const message = error && error.message ? error.message : String(error);
|
|
455
|
+
console.error(` Error: ${message}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let executor;
|
|
460
|
+
try {
|
|
461
|
+
executor = loadApplyExecutor();
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(`Error: Failed to load apply executor: ${err.message}`);
|
|
464
|
+
const e = new Error(err.message);
|
|
465
|
+
e.exitCode = 2;
|
|
466
|
+
throw e;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const sqlStatements = alterResult.statements.map((s) => s.sql);
|
|
470
|
+
|
|
471
|
+
let applyResult;
|
|
472
|
+
try {
|
|
473
|
+
applyResult = await executor.applyStatements({
|
|
474
|
+
statements: sqlStatements,
|
|
475
|
+
dialect,
|
|
476
|
+
config,
|
|
477
|
+
onProgress: progressHandler
|
|
478
|
+
});
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (err && err.status === 'ROLLBACK') {
|
|
481
|
+
console.error('');
|
|
482
|
+
console.error('ROLLBACK applied. Database state unchanged.');
|
|
483
|
+
console.error(` Statements applied before rollback: ${err.applied || 0}`);
|
|
484
|
+
console.error(` Failed statement index: ${err.failedIndex}`);
|
|
485
|
+
const e = new Error(err.message || 'ROLLBACK applied');
|
|
486
|
+
e.exitCode = 2;
|
|
487
|
+
throw e;
|
|
488
|
+
}
|
|
489
|
+
const message = err && err.message ? err.message : String(err);
|
|
490
|
+
console.error(`Error: ${message}`);
|
|
491
|
+
const e = new Error(message);
|
|
492
|
+
e.exitCode = 2;
|
|
493
|
+
throw e;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
console.log('');
|
|
497
|
+
if (applyResult && applyResult.status === 'SUCCESS') {
|
|
498
|
+
console.log('Summary:');
|
|
499
|
+
console.log(` ${applyResult.applied} statement(s) applied successfully in ${applyResult.durationMs}ms.`);
|
|
500
|
+
|
|
501
|
+
for (const sk of alterResult.skipped) {
|
|
502
|
+
console.log(` ⚠ Skipped: ${sk.target} (${sk.reason})`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const exitCode = buildExitCode(alterResult);
|
|
506
|
+
if (exitCode !== 0) {
|
|
507
|
+
const err = new Error(`schema apply: ${alterResult.summary.totalSkipped} drift entries require opt-in`);
|
|
508
|
+
err.exitCode = exitCode;
|
|
509
|
+
throw err;
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (applyResult && applyResult.status === 'PARTIAL') {
|
|
515
|
+
console.error('Partial apply detected:');
|
|
516
|
+
console.error(` ${applyResult.applied || 0} applied`);
|
|
517
|
+
console.error(` ${applyResult.failed || 1} failed`);
|
|
518
|
+
const err = new Error('Partial apply detected. Manual cleanup may be required.');
|
|
519
|
+
err.exitCode = 2;
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
console.log(`Status: ${applyResult ? applyResult.status : 'UNKNOWN'}`);
|
|
524
|
+
}
|
|
525
|
+
};
|