supatool 0.4.1 → 0.4.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.
@@ -33,6 +33,19 @@ Common Options:
33
33
  --config <path> Configuration file path
34
34
  -f, --force Force overwrite
35
35
 
36
+ seed command:
37
+ supatool seed -c <connection> [-t tables.yaml] [-o supabase/seeds]
38
+
39
+ tables.yaml format (schema-grouped):
40
+ public:
41
+ - users
42
+ - posts
43
+ admin:
44
+ - platforms
45
+
46
+ Output: supabase/seeds/<timestamp>/<schema>/<table>_seed.json
47
+ supabase/seeds/llms.txt (index for AI)
48
+
36
49
  For details, see the documentation.
37
50
  `;
38
51
  // Model Schema Usage
@@ -355,6 +355,26 @@ async function fetchFunctions(client, spinner, progress, schemas = ['public']) {
355
355
  timestamp: Math.floor(Date.now() / 1000)
356
356
  });
357
357
  }
358
+ // Detect overloaded functions (same schema.name, different signatures)
359
+ const nameCount = new Map();
360
+ for (const row of result.rows) {
361
+ const key = `${row.schema_name}.${row.name}`;
362
+ const sig = `${row.name}(${row.identity_args || ''})`;
363
+ if (!nameCount.has(key))
364
+ nameCount.set(key, []);
365
+ nameCount.get(key).push(sig);
366
+ }
367
+ const overloads = [...nameCount.entries()].filter(([, sigs]) => sigs.length > 1);
368
+ if (overloads.length > 0) {
369
+ console.warn('\n⚠ Overloaded RPC functions detected (same name, different signatures):');
370
+ for (const [key, sigs] of overloads) {
371
+ console.warn(` ${key}`);
372
+ for (const sig of sigs) {
373
+ console.warn(` - ${sig}`);
374
+ }
375
+ }
376
+ console.warn(' Note: Only the last definition will be written to the output file.\n');
377
+ }
358
378
  return functions;
359
379
  }
360
380
  /**
@@ -843,19 +863,21 @@ async function generateCreateTableDDL(client, tableName, schemaName = 'public')
843
863
  const [columnsResult, primaryKeyResult, tableCommentResult, columnCommentsResult, uniqueConstraintResult, foreignKeyResult] = await Promise.all([
844
864
  // Get column info
845
865
  client.query(`
846
- SELECT
866
+ SELECT
847
867
  c.column_name,
848
868
  c.data_type,
849
869
  c.udt_name,
850
870
  c.character_maximum_length,
851
871
  c.is_nullable,
852
872
  c.column_default,
873
+ c.is_generated,
874
+ c.generation_expression,
853
875
  pg_catalog.format_type(a.atttypid, a.atttypmod) AS full_type
854
876
  FROM information_schema.columns c
855
877
  JOIN pg_class cl ON cl.relname = c.table_name
856
878
  JOIN pg_namespace ns ON ns.nspname = c.table_schema AND ns.oid = cl.relnamespace
857
879
  JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attname = c.column_name
858
- WHERE c.table_schema = $1
880
+ WHERE c.table_schema = $1
859
881
  AND c.table_name = $2
860
882
  ORDER BY c.ordinal_position
861
883
  `, [schemaName, tableName]),
@@ -951,13 +973,19 @@ async function generateCreateTableDDL(client, tableName, schemaName = 'public')
951
973
  if (col.character_maximum_length) {
952
974
  colDef += `(${col.character_maximum_length})`;
953
975
  }
954
- // NOT NULL constraint
955
- if (col.is_nullable === 'NO') {
956
- colDef += ' NOT NULL';
976
+ // Generated column
977
+ if (col.is_generated === 'ALWAYS') {
978
+ colDef += ` GENERATED ALWAYS AS (${col.generation_expression}) STORED`;
957
979
  }
958
- // Default value
959
- if (col.column_default) {
960
- colDef += ` DEFAULT ${col.column_default}`;
980
+ else {
981
+ // NOT NULL constraint
982
+ if (col.is_nullable === 'NO') {
983
+ colDef += ' NOT NULL';
984
+ }
985
+ // Default value
986
+ if (col.column_default) {
987
+ colDef += ` DEFAULT ${col.column_default}`;
988
+ }
961
989
  }
962
990
  columnDefs.push(colDef);
963
991
  }
@@ -1041,7 +1069,12 @@ async function saveDefinitionsByType(definitions, outputDir, separateDirectories
1041
1069
  list.push(t.ddl);
1042
1070
  triggersByCategory.set(t.category, list);
1043
1071
  }
1044
- // Build merged DDL (table/view + RLS + triggers)
1072
+ // schema.table -> RLS status (for appending comment/DDL when no policies)
1073
+ const rlsStatusByCategory = new Map();
1074
+ for (const s of tableRlsStatus) {
1075
+ rlsStatusByCategory.set(`${s.schema}.${s.table}`, s);
1076
+ }
1077
+ // Build merged DDL (table/view + RLS + triggers). Tables with RLS disabled or 0 policies get a comment block in the file.
1045
1078
  const mergeRlsAndTriggers = (def) => {
1046
1079
  const cat = def.schema && def.name ? `${def.schema}.${def.name}` : def.category ?? '';
1047
1080
  let ddl = def.ddl.trimEnd();
@@ -1049,6 +1082,19 @@ async function saveDefinitionsByType(definitions, outputDir, separateDirectories
1049
1082
  if (rlsDdl) {
1050
1083
  ddl += '\n\n' + rlsDdl.trim();
1051
1084
  }
1085
+ else if (def.type === 'table' && def.schema && def.name) {
1086
+ const rlsStatus = rlsStatusByCategory.get(cat);
1087
+ if (rlsStatus) {
1088
+ if (!rlsStatus.rlsEnabled) {
1089
+ ddl += '\n\n-- RLS: disabled. Consider enabling for production.';
1090
+ ddl += '\n-- ALTER TABLE ' + def.schema + '.' + def.name + ' ENABLE ROW LEVEL SECURITY;';
1091
+ }
1092
+ else if (rlsStatus.policyCount === 0) {
1093
+ ddl += '\n\n-- RLS: enabled, no policies defined';
1094
+ ddl += '\nALTER TABLE ' + def.schema + '.' + def.name + ' ENABLE ROW LEVEL SECURITY;';
1095
+ }
1096
+ }
1097
+ }
1052
1098
  const trgList = triggersByCategory.get(cat);
1053
1099
  if (trgList && trgList.length > 0) {
1054
1100
  ddl += '\n\n' + trgList.map(t => t.trim()).join('\n\n');
@@ -1091,13 +1137,7 @@ async function saveDefinitionsByType(definitions, outputDir, separateDirectories
1091
1137
  if (!fs.existsSync(targetDir)) {
1092
1138
  fs.mkdirSync(targetDir, { recursive: true });
1093
1139
  }
1094
- let fileName;
1095
- if (def.type === 'function') {
1096
- fileName = `fn_${def.name}.sql`;
1097
- }
1098
- else {
1099
- fileName = `${def.name}.sql`;
1100
- }
1140
+ const fileName = `${def.name}.sql`;
1101
1141
  const filePath = path.join(targetDir, fileName);
1102
1142
  const ddlWithNewline = def.ddl.endsWith('\n') ? def.ddl : def.ddl + '\n';
1103
1143
  await fsPromises.writeFile(filePath, headerComment + ddlWithNewline);
@@ -1157,7 +1197,7 @@ async function generateIndexFile(definitions, outputDir, separateDirectories = t
1157
1197
  // Build relative path per file (schema/type/file when multiSchema)
1158
1198
  const getRelPath = (def) => {
1159
1199
  const typeDir = separateDirectories ? (typeDirNames[def.type] ?? def.type) : '.';
1160
- const fileName = def.type === 'function' ? `fn_${def.name}.sql` : `${def.name}.sql`;
1200
+ const fileName = `${def.name}.sql`;
1161
1201
  if (multiSchema && def.schema) {
1162
1202
  return `${def.schema}/${typeDir}/${fileName}`;
1163
1203
  }
@@ -8,17 +8,55 @@ const pg_1 = require("pg");
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const js_yaml_1 = __importDefault(require("js-yaml"));
11
+ /**
12
+ * Parse tables.yaml into { schema -> table[] } map.
13
+ * Format:
14
+ * public:
15
+ * - users
16
+ * - posts
17
+ * admin:
18
+ * - platforms
19
+ */
20
+ function parseTablesYaml(yamlPath) {
21
+ const yamlObj = js_yaml_1.default.load(fs_1.default.readFileSync(yamlPath, 'utf8'));
22
+ if (!yamlObj || typeof yamlObj !== 'object' || Array.isArray(yamlObj)) {
23
+ throw new Error('Invalid tables.yaml format. Use schema-grouped format:\n public:\n - users\n admin:\n - platforms');
24
+ }
25
+ // Detect old format: top-level "tables:" key with array value
26
+ if ('tables' in yamlObj && Array.isArray(yamlObj.tables)) {
27
+ throw new Error('Outdated tables.yaml format detected.\n\n' +
28
+ 'Migrate to schema-grouped format:\n\n' +
29
+ ' Before: After:\n' +
30
+ ' tables: public:\n' +
31
+ ' - users → - users\n' +
32
+ ' - posts - posts\n' +
33
+ ' - admin.x admin:\n' +
34
+ ' - x\n');
35
+ }
36
+ const entries = [];
37
+ for (const [schema, tables] of Object.entries(yamlObj)) {
38
+ if (!Array.isArray(tables)) {
39
+ throw new Error(`tables.yaml: value of "${schema}" must be a list of table names`);
40
+ }
41
+ for (const table of tables) {
42
+ // Detect dot notation inside a schema group
43
+ if (table.includes('.')) {
44
+ throw new Error(`tables.yaml: "${table}" under "${schema}" uses dot notation.\n` +
45
+ `Use schema-grouped format instead:\n` +
46
+ ` ${table.split('.')[0]}:\n` +
47
+ ` - ${table.split('.')[1]}`);
48
+ }
49
+ entries.push({ schema, table });
50
+ }
51
+ }
52
+ return entries;
53
+ }
11
54
  /**
12
55
  * Fetch table data from remote DB and generate AI seed JSON
13
56
  * @param options SeedGenOptions
14
57
  */
15
58
  async function generateSeedsFromRemote(options) {
16
- // Load tables.yaml
17
- const yamlObj = js_yaml_1.default.load(fs_1.default.readFileSync(options.tablesYamlPath, 'utf8'));
18
- if (!yamlObj || !Array.isArray(yamlObj.tables)) {
19
- throw new Error('Invalid tables.yaml format. Specify as tables: [ ... ]');
20
- }
21
- const tables = yamlObj.tables;
59
+ const tables = parseTablesYaml(options.tablesYamlPath);
22
60
  // Generate datetime subdir name (e.g. 20250705_1116_supatool)
23
61
  const now = new Date();
24
62
  const y = now.getFullYear();
@@ -28,67 +66,54 @@ async function generateSeedsFromRemote(options) {
28
66
  const mm = String(now.getMinutes()).padStart(2, '0');
29
67
  const folderName = `${y}${m}${d}_${hh}${mm}_supatool`;
30
68
  const outDir = path_1.default.join(options.outputDir, folderName);
31
- // Create output directory
32
- if (!fs_1.default.existsSync(outDir)) {
33
- fs_1.default.mkdirSync(outDir, { recursive: true });
34
- }
35
69
  // DB connection
36
70
  const client = new pg_1.Client({ connectionString: options.connectionString });
37
71
  await client.connect();
38
- let processedCount = 0;
39
- for (const tableFullName of tables) {
40
- // No schema specified -> public
41
- let schema = 'public';
42
- let table = tableFullName;
43
- if (tableFullName.includes('.')) {
44
- [schema, table] = tableFullName.split('.');
72
+ const processedFiles = [];
73
+ for (const { schema, table } of tables) {
74
+ // Create schema subdir
75
+ const schemaDir = path_1.default.join(outDir, schema);
76
+ if (!fs_1.default.existsSync(schemaDir)) {
77
+ fs_1.default.mkdirSync(schemaDir, { recursive: true });
45
78
  }
46
79
  // Fetch data
47
80
  const res = await client.query(`SELECT * FROM "${schema}"."${table}"`);
48
81
  const rows = res.rows;
49
- // File name
50
- const fileName = `${table}_seed.json`;
51
- const filePath = path_1.default.join(outDir, fileName);
52
82
  // Output JSON
83
+ const fileName = `${table}_seed.json`;
84
+ const filePath = path_1.default.join(schemaDir, fileName);
53
85
  const json = {
54
86
  table: `${schema}.${table}`,
55
87
  fetched_at: now.toISOString(),
56
- fetched_by: 'supatool v0.3.5',
88
+ fetched_by: 'supatool',
57
89
  note: 'This data is a snapshot of the remote DB at the above time. For AI coding reference. You can update it by running the update command again.',
58
90
  rows
59
91
  };
60
92
  fs_1.default.writeFileSync(filePath, JSON.stringify(json, null, 2), 'utf8');
61
- processedCount++;
93
+ processedFiles.push({ schema, table, fileName, rowCount: rows.length });
62
94
  }
63
95
  await client.end();
64
- // llms.txt index output (overwrite under supabase/seeds each run)
65
- const files = fs_1.default.readdirSync(outDir);
66
- const seedFiles = files.filter(f => f.endsWith('_seed.json'));
96
+ // llms.txt index (schema/file paths, overwrite each run)
67
97
  let llmsTxt = `# AI seed data index (generated by supatool)\n`;
68
98
  llmsTxt += `# fetched_at: ${now.toISOString()}\n`;
69
99
  llmsTxt += `# folder: ${folderName}\n`;
70
100
  llmsTxt += `# Schema catalog: ../schemas/llms.txt\n`;
71
- for (const basename of seedFiles) {
72
- const file = path_1.default.join(outDir, basename);
73
- const content = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
74
- // Table comment (empty if none)
101
+ for (const { schema, table, fileName, rowCount } of processedFiles) {
75
102
  let tableComment = '';
76
103
  try {
77
- const [schema, table] = content.table.split('.');
78
104
  const commentRes = await getTableComment(options.connectionString, schema, table);
79
105
  if (commentRes)
80
106
  tableComment = commentRes;
81
107
  }
82
108
  catch { }
83
- llmsTxt += `${content.table}: ${basename} (${Array.isArray(content.rows) ? content.rows.length : 0} rows)`;
109
+ llmsTxt += `${schema}.${table}: ${schema}/${fileName} (${rowCount} rows)`;
84
110
  if (tableComment)
85
111
  llmsTxt += ` # ${tableComment}`;
86
112
  llmsTxt += `\n`;
87
113
  }
88
114
  const llmsPath = path_1.default.join(options.outputDir, 'llms.txt');
89
115
  fs_1.default.writeFileSync(llmsPath, llmsTxt, 'utf8');
90
- // Output summary in English
91
- console.log(`Seed export completed. Processed tables: ${processedCount}`);
116
+ console.log(`Seed export completed. Processed tables: ${processedFiles.length}`);
92
117
  console.log(`llms.txt index written to: ${llmsPath}`);
93
118
  }
94
119
  /** Utility to get table comment */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supatool",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "CLI for Supabase: extract schema (tables, views, RLS, RPC) to files + llms.txt for LLM, deploy local schema, seed export. CRUD code gen deprecated.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",