supatool 0.6.1 → 0.6.3
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/dist/bin/helptext.js +1 -0
- package/dist/bin/supatool.js +14 -0
- package/dist/sync/definitionExtractor.js +88 -66
- package/package.json +1 -1
package/dist/bin/helptext.js
CHANGED
|
@@ -25,6 +25,7 @@ Common Options:
|
|
|
25
25
|
-c, --connection <string> Connection string (postgresql:// or postgres://)
|
|
26
26
|
-o, --output-dir <path> Output directory
|
|
27
27
|
--schema <schemas> Target schemas (comma-separated, default: public)
|
|
28
|
+
--schema-only Regenerate only the specified schema files; skip index files
|
|
28
29
|
--config <path> Configuration file path
|
|
29
30
|
-f, --force Force overwrite
|
|
30
31
|
|
package/dist/bin/supatool.js
CHANGED
|
@@ -77,6 +77,7 @@ program
|
|
|
77
77
|
.option('--schema <schemas>', 'Target schemas, comma-separated (default: public)')
|
|
78
78
|
.option('--all-schemas', 'Target all schemas in the DB (use with -e to exclude some)')
|
|
79
79
|
.option('-e, --exclude-schema <schemas>', 'Schemas to exclude, comma-separated. Without --schema, targets all schemas automatically.')
|
|
80
|
+
.option('--schema-only', 'Regenerate only the specified schema files; do not touch other schemas or index files (llms.txt, schema_index.json, etc.)')
|
|
80
81
|
.option('--config <path>', 'Configuration file path')
|
|
81
82
|
.option('-f, --force', 'Force overwrite without confirmation')
|
|
82
83
|
.action(async (options) => {
|
|
@@ -86,6 +87,18 @@ program
|
|
|
86
87
|
if (!config.connectionString)
|
|
87
88
|
connectionRequiredError();
|
|
88
89
|
try {
|
|
90
|
+
if (options.schemaOnly && !options.schema) {
|
|
91
|
+
console.error('⚠️ --schema-only requires --schema to be specified.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
if (options.schemaOnly && options.all) {
|
|
95
|
+
console.error('⚠️ --schema-only cannot be combined with --all. Use --schema-only without --all to regenerate only the specified schema files.');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
if (options.schemaOnly && options.allSchemas) {
|
|
99
|
+
console.error('⚠️ --schema-only cannot be combined with --all-schemas.');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
89
102
|
let schemas = ['public'];
|
|
90
103
|
if (options.schema) {
|
|
91
104
|
schemas = options.schema.split(',').map((s) => s.trim());
|
|
@@ -106,6 +119,7 @@ program
|
|
|
106
119
|
allSchemas: options.allSchemas || false,
|
|
107
120
|
excludeSchemas,
|
|
108
121
|
schemasExplicit: !!options.schema,
|
|
122
|
+
schemaOnly: options.schemaOnly || false,
|
|
109
123
|
version: package_json_1.version
|
|
110
124
|
});
|
|
111
125
|
}
|
|
@@ -257,12 +257,11 @@ async function fetchRelationList(client, schemas = ['public']) {
|
|
|
257
257
|
*/
|
|
258
258
|
async function fetchAllSchemas(client) {
|
|
259
259
|
const result = await client.query(`
|
|
260
|
-
SELECT schema_name
|
|
261
|
-
FROM
|
|
262
|
-
WHERE
|
|
263
|
-
AND
|
|
264
|
-
|
|
265
|
-
ORDER BY schema_name
|
|
260
|
+
SELECT nspname AS schema_name
|
|
261
|
+
FROM pg_catalog.pg_namespace
|
|
262
|
+
WHERE nspname NOT LIKE 'pg_%'
|
|
263
|
+
AND nspname != 'information_schema'
|
|
264
|
+
ORDER BY nspname
|
|
266
265
|
`);
|
|
267
266
|
return result.rows.map((r) => r.schema_name);
|
|
268
267
|
}
|
|
@@ -928,23 +927,30 @@ async function generateCreateTableDDL(client, tableName, schemaName = 'public')
|
|
|
928
927
|
ORDER BY tc.constraint_name
|
|
929
928
|
`, [schemaName, tableName]),
|
|
930
929
|
// Get FOREIGN KEY constraints
|
|
930
|
+
// Use pg_constraint directly to avoid the N² row explosion that occurs when
|
|
931
|
+
// joining kcu × ccu on constraint_name alone for composite FKs.
|
|
931
932
|
client.query(`
|
|
932
|
-
SELECT
|
|
933
|
-
|
|
934
|
-
string_agg(
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
933
|
+
SELECT
|
|
934
|
+
c.conname AS constraint_name,
|
|
935
|
+
(SELECT string_agg(a.attname, ', ' ORDER BY u.ord)
|
|
936
|
+
FROM unnest(c.conkey) WITH ORDINALITY AS u(attnum, ord)
|
|
937
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = u.attnum
|
|
938
|
+
) AS columns,
|
|
939
|
+
fn.nspname AS foreign_table_schema,
|
|
940
|
+
fc.relname AS foreign_table_name,
|
|
941
|
+
(SELECT string_agg(a.attname, ', ' ORDER BY u.ord)
|
|
942
|
+
FROM unnest(c.confkey) WITH ORDINALITY AS u(attnum, ord)
|
|
943
|
+
JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = u.attnum
|
|
944
|
+
) AS foreign_columns
|
|
945
|
+
FROM pg_constraint c
|
|
946
|
+
JOIN pg_class rel ON rel.oid = c.conrelid
|
|
947
|
+
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
|
948
|
+
JOIN pg_class fc ON fc.oid = c.confrelid
|
|
949
|
+
JOIN pg_namespace fn ON fn.oid = fc.relnamespace
|
|
950
|
+
WHERE c.contype = 'f'
|
|
951
|
+
AND nsp.nspname = $1
|
|
952
|
+
AND rel.relname = $2
|
|
953
|
+
ORDER BY c.conname
|
|
948
954
|
`, [schemaName, tableName])
|
|
949
955
|
]);
|
|
950
956
|
const columnComments = new Map();
|
|
@@ -1039,7 +1045,7 @@ async function generateCreateTableDDL(client, tableName, schemaName = 'public')
|
|
|
1039
1045
|
/**
|
|
1040
1046
|
* Save definitions to files (merge RLS/triggers into table/view; schema folders when multi-schema)
|
|
1041
1047
|
*/
|
|
1042
|
-
async function saveDefinitionsByType(definitions, outputDir, separateDirectories = true, schemas = ['public'], relations = [], rpcTables = [], allSchemas = [], version, tableRlsStatus = [], force = false) {
|
|
1048
|
+
async function saveDefinitionsByType(definitions, outputDir, separateDirectories = true, schemas = ['public'], relations = [], rpcTables = [], allSchemas = [], version, tableRlsStatus = [], force = false, schemaOnly = false) {
|
|
1043
1049
|
const fs = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
1044
1050
|
const path = await Promise.resolve().then(() => __importStar(require('path')));
|
|
1045
1051
|
const outputDate = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
@@ -1116,7 +1122,10 @@ async function saveDefinitionsByType(definitions, outputDir, separateDirectories
|
|
|
1116
1122
|
...cronJobs,
|
|
1117
1123
|
...customTypes
|
|
1118
1124
|
];
|
|
1119
|
-
|
|
1125
|
+
// schemaOnly always uses schema subdirectories (outputDir/<schema>/type/)
|
|
1126
|
+
// so that single-schema --schema-only runs write to the correct location
|
|
1127
|
+
// and don't interfere with other schemas already on disk.
|
|
1128
|
+
const multiSchema = schemas.length > 1 || schemaOnly;
|
|
1120
1129
|
const typeDirNames = {
|
|
1121
1130
|
table: 'tables',
|
|
1122
1131
|
view: 'views',
|
|
@@ -1153,7 +1162,8 @@ async function saveDefinitionsByType(definitions, outputDir, separateDirectories
|
|
|
1153
1162
|
}
|
|
1154
1163
|
await fsPromises.writeFile(filePath, newContent);
|
|
1155
1164
|
}
|
|
1156
|
-
// When force: delete SQL files that no longer correspond to any extracted object
|
|
1165
|
+
// When force: delete SQL files that no longer correspond to any extracted object.
|
|
1166
|
+
// With schemaOnly: restrict deletion to the target schema directories only.
|
|
1157
1167
|
if (force && fs.existsSync(outputDir)) {
|
|
1158
1168
|
const deleteStaleSqlFiles = (dir) => {
|
|
1159
1169
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1166,10 +1176,41 @@ async function saveDefinitionsByType(definitions, outputDir, separateDirectories
|
|
|
1166
1176
|
}
|
|
1167
1177
|
}
|
|
1168
1178
|
};
|
|
1169
|
-
|
|
1179
|
+
if (schemaOnly) {
|
|
1180
|
+
// Only scan within the target schema directories
|
|
1181
|
+
for (const schemaName of schemas) {
|
|
1182
|
+
const schemaDir = path.join(outputDir, schemaName);
|
|
1183
|
+
if (fs.existsSync(schemaDir)) {
|
|
1184
|
+
deleteStaleSqlFiles(schemaDir);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
deleteStaleSqlFiles(outputDir);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// When schemaOnly: skip index file regeneration to avoid overwriting
|
|
1193
|
+
// shared index files (llms.txt, schema_index.json, etc.) with partial data.
|
|
1194
|
+
if (schemaOnly) {
|
|
1195
|
+
return;
|
|
1170
1196
|
}
|
|
1171
1197
|
await generateIndexFile(toWrite, outputDir, separateDirectories, multiSchema, relations, rpcTables, allSchemas, schemas, version, tableRlsStatus);
|
|
1172
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Write a file only when content has changed, to avoid unnecessary Git diffs.
|
|
1201
|
+
* When skipFirstLine is true, the first line (typically a date-bearing header) is
|
|
1202
|
+
* excluded from the comparison so that daily date changes don't trigger writes.
|
|
1203
|
+
*/
|
|
1204
|
+
function writeFileIfChanged(filePath, newContent, skipFirstLine = false) {
|
|
1205
|
+
const fs = require('fs');
|
|
1206
|
+
if (fs.existsSync(filePath)) {
|
|
1207
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
1208
|
+
const strip = (c) => skipFirstLine ? c.split('\n').slice(1).join('\n') : c;
|
|
1209
|
+
if (strip(existing) === strip(newContent))
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
fs.writeFileSync(filePath, newContent, 'utf8');
|
|
1213
|
+
}
|
|
1173
1214
|
/**
|
|
1174
1215
|
* Generate index file for DB objects (RLS/triggers already merged into table/view)
|
|
1175
1216
|
*/
|
|
@@ -1291,8 +1332,8 @@ async function generateIndexFile(definitions, outputDir, separateDirectories = t
|
|
|
1291
1332
|
}
|
|
1292
1333
|
const readmePath = path.join(outputDir, 'README.md');
|
|
1293
1334
|
const llmsPath = path.join(outputDir, 'llms.txt');
|
|
1294
|
-
|
|
1295
|
-
|
|
1335
|
+
writeFileIfChanged(readmePath, readmeContent, true);
|
|
1336
|
+
writeFileIfChanged(llmsPath, llmsContent, true);
|
|
1296
1337
|
// schema_index.json (same data for agents that parse JSON)
|
|
1297
1338
|
const schemaIndex = {
|
|
1298
1339
|
objects: definitions.map(def => {
|
|
@@ -1319,7 +1360,7 @@ async function generateIndexFile(definitions, outputDir, separateDirectories = t
|
|
|
1319
1360
|
}
|
|
1320
1361
|
: undefined
|
|
1321
1362
|
};
|
|
1322
|
-
|
|
1363
|
+
writeFileIfChanged(path.join(outputDir, 'schema_index.json'), JSON.stringify(schemaIndex, null, 2) + '\n');
|
|
1323
1364
|
// schema_summary.md (one-file overview for AI) — include RLS status per table
|
|
1324
1365
|
let summaryMd = '# Schema summary\n\n';
|
|
1325
1366
|
const tableDefs = definitions.filter(d => d.type === 'table' || d.type === 'view');
|
|
@@ -1352,7 +1393,7 @@ async function generateIndexFile(definitions, outputDir, separateDirectories = t
|
|
|
1352
1393
|
summaryMd += `- Extracted: ${allSchemas.filter(s => extractedSet.has(s)).join(', ') || '(none)'}\n`;
|
|
1353
1394
|
summaryMd += `- Not extracted: ${allSchemas.filter(s => !extractedSet.has(s)).join(', ') || '(none)'}\n`;
|
|
1354
1395
|
}
|
|
1355
|
-
|
|
1396
|
+
writeFileIfChanged(path.join(outputDir, 'schema_summary.md'), summaryMd);
|
|
1356
1397
|
// RLS disabled tables warning doc (tables only; RLS enabled with 0 policies is not warned)
|
|
1357
1398
|
const rlsNotEnabled = tableRlsStatus.filter(s => !s.rlsEnabled);
|
|
1358
1399
|
if (rlsNotEnabled.length > 0) {
|
|
@@ -1363,14 +1404,14 @@ async function generateIndexFile(definitions, outputDir, separateDirectories = t
|
|
|
1363
1404
|
rlsNotEnabled.forEach(s => {
|
|
1364
1405
|
warnMd += `| ${s.schema} | ${s.table} |\n`;
|
|
1365
1406
|
});
|
|
1366
|
-
|
|
1407
|
+
writeFileIfChanged(path.join(outputDir, 'rls_warnings.md'), warnMd);
|
|
1367
1408
|
}
|
|
1368
1409
|
}
|
|
1369
1410
|
/**
|
|
1370
1411
|
* Classify and output definitions
|
|
1371
1412
|
*/
|
|
1372
1413
|
async function extractDefinitions(options) {
|
|
1373
|
-
const { connectionString, outputDir, separateDirectories = true, tablesOnly = false, viewsOnly = false, all = false, tablePattern = '*', force = false, schemas: schemasOption = ['public'], excludeSchemas = [], allSchemas: useAllSchemas = false, schemasExplicit = false, version } = options;
|
|
1414
|
+
const { connectionString, outputDir, separateDirectories = true, tablesOnly = false, viewsOnly = false, all = false, tablePattern = '*', force = false, schemas: schemasOption = ['public'], excludeSchemas = [], allSchemas: useAllSchemas = false, schemasExplicit = false, schemaOnly = false, version } = options;
|
|
1374
1415
|
// schemas will be resolved after DB connect when useAllSchemas is true
|
|
1375
1416
|
let schemas = schemasOption;
|
|
1376
1417
|
// Disable Node.js SSL certificate verification
|
|
@@ -1390,18 +1431,13 @@ async function extractDefinitions(options) {
|
|
|
1390
1431
|
}
|
|
1391
1432
|
// URL encode password part
|
|
1392
1433
|
let encodedConnectionString = connectionString;
|
|
1393
|
-
console.log('🔍 Original connection string:', connectionString);
|
|
1394
1434
|
try {
|
|
1395
1435
|
// Special handling when password contains @
|
|
1396
1436
|
if (connectionString.includes('@') && connectionString.split('@').length > 2) {
|
|
1397
|
-
console.log('⚠️ Password contains @, executing special handling');
|
|
1398
1437
|
// Use last @ as delimiter
|
|
1399
1438
|
const parts = connectionString.split('@');
|
|
1400
1439
|
const lastPart = parts.pop(); // Last part (host:port/database)
|
|
1401
1440
|
const firstParts = parts.join('@'); // First part (postgresql://user:password)
|
|
1402
|
-
console.log(' Split result:');
|
|
1403
|
-
console.log(' First part:', firstParts);
|
|
1404
|
-
console.log(' Last part:', lastPart);
|
|
1405
1441
|
// Encode password part
|
|
1406
1442
|
const colonIndex = firstParts.lastIndexOf(':');
|
|
1407
1443
|
if (colonIndex > 0) {
|
|
@@ -1409,50 +1445,40 @@ async function extractDefinitions(options) {
|
|
|
1409
1445
|
const password = firstParts.substring(colonIndex + 1);
|
|
1410
1446
|
const encodedPassword = encodeURIComponent(password);
|
|
1411
1447
|
encodedConnectionString = `${protocolAndUser}:${encodedPassword}@${lastPart}`;
|
|
1412
|
-
console.log(' Encode result:');
|
|
1413
|
-
console.log(' Protocol+User:', protocolAndUser);
|
|
1414
|
-
console.log(' Original password:', password);
|
|
1415
|
-
console.log(' Encoded password:', encodedPassword);
|
|
1416
|
-
console.log(' Final connection string:', encodedConnectionString);
|
|
1417
1448
|
}
|
|
1418
1449
|
}
|
|
1419
1450
|
else {
|
|
1420
|
-
console.log('✅ Executing normal URL parsing');
|
|
1421
1451
|
// Normal URL parsing
|
|
1422
1452
|
const url = new URL(connectionString);
|
|
1423
|
-
// Handle username containing dots
|
|
1424
|
-
if (url.username && url.username.includes('.')) {
|
|
1425
|
-
console.log(`Username (with dots): ${url.username}`);
|
|
1426
|
-
}
|
|
1427
1453
|
if (url.password) {
|
|
1428
1454
|
// Encode only password part
|
|
1429
1455
|
const encodedPassword = encodeURIComponent(url.password);
|
|
1430
1456
|
url.password = encodedPassword;
|
|
1431
1457
|
encodedConnectionString = url.toString();
|
|
1432
|
-
console.log(' Password encoded:', encodedPassword);
|
|
1433
1458
|
}
|
|
1434
1459
|
}
|
|
1435
1460
|
// Add SSL settings for Supabase connection
|
|
1436
1461
|
if (!encodedConnectionString.includes('sslmode=')) {
|
|
1437
1462
|
const separator = encodedConnectionString.includes('?') ? '&' : '?';
|
|
1438
1463
|
encodedConnectionString += `${separator}sslmode=require`;
|
|
1439
|
-
console.log(' SSL setting added:', encodedConnectionString);
|
|
1440
1464
|
}
|
|
1441
|
-
//
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1465
|
+
// Debug-only: connection info (no credentials)
|
|
1466
|
+
if (process.env.SUPATOOL_DEBUG) {
|
|
1467
|
+
const debugUrl = new URL(encodedConnectionString);
|
|
1468
|
+
console.log('🔍 Connection info:');
|
|
1469
|
+
console.log(` Host: ${debugUrl.hostname}`);
|
|
1470
|
+
console.log(` Port: ${debugUrl.port}`);
|
|
1471
|
+
console.log(` Database: ${debugUrl.pathname.slice(1)}`);
|
|
1472
|
+
console.log(` User: ${debugUrl.username}`);
|
|
1473
|
+
console.log(` SSL: ${debugUrl.searchParams.get('sslmode') || 'require'}`);
|
|
1474
|
+
}
|
|
1451
1475
|
}
|
|
1452
1476
|
catch (error) {
|
|
1453
1477
|
// Use original string if URL parsing fails
|
|
1454
|
-
|
|
1455
|
-
|
|
1478
|
+
if (process.env.SUPATOOL_DEBUG) {
|
|
1479
|
+
console.warn('Failed to parse connection string URL. May contain special characters.');
|
|
1480
|
+
console.warn('Error details:', error instanceof Error ? error.message : String(error));
|
|
1481
|
+
}
|
|
1456
1482
|
}
|
|
1457
1483
|
const fs = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
1458
1484
|
const readline = await Promise.resolve().then(() => __importStar(require('readline')));
|
|
@@ -1485,10 +1511,6 @@ async function extractDefinitions(options) {
|
|
|
1485
1511
|
}
|
|
1486
1512
|
});
|
|
1487
1513
|
try {
|
|
1488
|
-
// Debug before connect
|
|
1489
|
-
console.log('🔧 Connection settings:');
|
|
1490
|
-
console.log(` SSL: rejectUnauthorized=false`);
|
|
1491
|
-
console.log(` Connection string length: ${encodedConnectionString.length}`);
|
|
1492
1514
|
await client.connect();
|
|
1493
1515
|
spinner.text = 'Connected to database';
|
|
1494
1516
|
// Resolve schemas: --all-schemas or -e alone (without explicit --schema) → fetch all from DB and subtract excludeSchemas
|
|
@@ -1653,7 +1675,7 @@ async function extractDefinitions(options) {
|
|
|
1653
1675
|
}
|
|
1654
1676
|
// Save definitions (table+RLS+triggers merged, schema folders)
|
|
1655
1677
|
spinner.text = 'Saving definitions to files...';
|
|
1656
|
-
await saveDefinitionsByType(allDefinitions, outputDir, separateDirectories, schemas, relations, rpcTables, allSchemas, version, tableRlsStatus, force);
|
|
1678
|
+
await saveDefinitionsByType(allDefinitions, outputDir, separateDirectories, schemas, relations, rpcTables, allSchemas, version, tableRlsStatus, force, schemaOnly);
|
|
1657
1679
|
// Warn at extract time when any table has RLS disabled
|
|
1658
1680
|
const rlsNotEnabled = tableRlsStatus.filter(s => !s.rlsEnabled);
|
|
1659
1681
|
if (rlsNotEnabled.length > 0) {
|
package/package.json
CHANGED