relq 1.0.34 → 1.0.36

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.
@@ -258,6 +258,7 @@ async function pullCommand(context) {
258
258
  { id: 'collations', label: 'collations', status: 'pending', count: 0 },
259
259
  { id: 'foreign_servers', label: 'foreign servers', status: 'pending', count: 0 },
260
260
  { id: 'foreign_tables', label: 'foreign tables', status: 'pending', count: 0 },
261
+ { id: 'types', label: 'types', status: 'pending', count: 0 },
261
262
  ];
262
263
  progress.setItems(progressItems);
263
264
  progress.start();
@@ -268,6 +269,9 @@ async function pullCommand(context) {
268
269
  progress.updateItem(update.step, { status: update.status, count: update.count });
269
270
  },
270
271
  });
272
+ progress.updateItem('types', { status: 'fetching', count: 0 });
273
+ const typesForProgress = await (0, types_manager_1.getTypesFromDb)(connection);
274
+ progress.updateItem('types', { status: 'done', count: typesForProgress.length });
271
275
  progress.complete();
272
276
  console.log('');
273
277
  const ignorePatterns = (0, relqignore_1.loadRelqignore)(projectRoot);
@@ -563,6 +567,38 @@ async function pullCommand(context) {
563
567
  console.log(`Synced ${typesResult.typesCount} type(s) from remote`);
564
568
  hasSynced = true;
565
569
  }
570
+ if (typesForProgress.length > 0 && fs.existsSync(schemaPath)) {
571
+ const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
572
+ const schemaBaseName = path.basename(schemaPath, '.ts');
573
+ const typesImportPattern = new RegExp(`from\\s+['"]\\./${schemaBaseName}\\.types['"]`);
574
+ const hasTypesImport = typesImportPattern.test(schemaContent);
575
+ const typesWithUsages = typesForProgress.filter(t => t.usages && t.usages.length > 0);
576
+ if (typesWithUsages.length > 0 && !hasTypesImport) {
577
+ console.log(`Applying ${typesWithUsages.length} type(s) to schema...`);
578
+ const columnTypeMap = {};
579
+ for (const typeDef of typesForProgress) {
580
+ if (typeDef.usages) {
581
+ for (const usage of typeDef.usages) {
582
+ columnTypeMap[usage] = typeDef.name;
583
+ }
584
+ }
585
+ }
586
+ const parsedSchema = await (0, ast_transformer_1.introspectedToParsedSchema)(dbSchema);
587
+ (0, ast_codegen_1.assignTrackingIds)(parsedSchema);
588
+ const typesImportPath = `./${schemaBaseName}.types`;
589
+ const typescript = (0, ast_codegen_1.generateTypeScriptFromAST)(parsedSchema, {
590
+ camelCase: config.generate?.camelCase ?? true,
591
+ importPath: 'relq/schema-builder',
592
+ includeFunctions: false,
593
+ includeTriggers: false,
594
+ columnTypeMap,
595
+ typesImportPath,
596
+ });
597
+ fs.writeFileSync(schemaPath, typescript, 'utf-8');
598
+ console.log(`Updated ${cli_utils_1.colors.cyan(schemaPath)} with type annotations`);
599
+ hasSynced = true;
600
+ }
601
+ }
566
602
  if (!hasSynced) {
567
603
  console.log('Already up to date with remote');
568
604
  }
@@ -682,11 +718,23 @@ async function pullCommand(context) {
682
718
  spinner.start('Generating TypeScript schema...');
683
719
  const parsedSchema = await (0, ast_transformer_1.introspectedToParsedSchema)(dbSchema);
684
720
  (0, ast_codegen_1.assignTrackingIds)(parsedSchema);
721
+ const columnTypeMap = {};
722
+ for (const typeDef of typesForProgress) {
723
+ if (typeDef.usages) {
724
+ for (const usage of typeDef.usages) {
725
+ columnTypeMap[usage] = typeDef.name;
726
+ }
727
+ }
728
+ }
729
+ const schemaBaseName = path.basename(schemaPath, '.ts');
730
+ const typesImportPath = `./${schemaBaseName}.types`;
685
731
  const typescript = (0, ast_codegen_1.generateTypeScriptFromAST)(parsedSchema, {
686
732
  camelCase: config.generate?.camelCase ?? true,
687
733
  importPath: 'relq/schema-builder',
688
734
  includeFunctions: false,
689
735
  includeTriggers: false,
736
+ columnTypeMap,
737
+ typesImportPath,
690
738
  });
691
739
  spinner.succeed('Generated TypeScript schema');
692
740
  const schemaDir = path.dirname(schemaPath);
@@ -151,6 +151,28 @@ async function pushCommand(context) {
151
151
  const schemaPath = (0, config_loader_2.getSchemaPath)(config);
152
152
  const typesFilePath = (0, types_manager_1.getTypesFilePath)(schemaPath);
153
153
  let typesSynced = false;
154
+ if (fs.existsSync(schemaPath)) {
155
+ spinner.start('Validating types configuration...');
156
+ const validationErrors = await (0, types_manager_1.validateTypesConfiguration)(schemaPath, typesFilePath);
157
+ if (validationErrors.length > 0) {
158
+ spinner.fail('Types validation failed');
159
+ console.log('');
160
+ for (const err of validationErrors) {
161
+ console.log(`${cli_utils_1.colors.red('error:')} ${err.message}`);
162
+ if (err.details && err.details.length > 0) {
163
+ for (const detail of err.details) {
164
+ console.log(` ${cli_utils_1.colors.muted('→')} ${detail}`);
165
+ }
166
+ }
167
+ if (err.hint) {
168
+ console.log(` ${cli_utils_1.colors.cyan('hint:')} ${err.hint}`);
169
+ }
170
+ console.log('');
171
+ }
172
+ (0, cli_utils_1.fatal)('Types configuration is invalid', `Fix the issues above and try again.\nTypes must be defined in ${cli_utils_1.colors.cyan(path.basename(typesFilePath))} to enable database syncing.`);
173
+ }
174
+ spinner.succeed('Types configuration valid');
175
+ }
154
176
  if (fs.existsSync(typesFilePath) && !dryRun) {
155
177
  spinner.start('Syncing TypeScript types...');
156
178
  const typesResult = await (0, types_manager_1.syncTypesToDb)(connection, typesFilePath, schemaPath);
@@ -168,7 +168,7 @@ function getExplicitFKName(constraintName, tableName, columnName) {
168
168
  }
169
169
  return constraintName;
170
170
  }
171
- function generateColumnCode(col, useCamelCase, enumNames, domainNames, checkOverride) {
171
+ function generateColumnCode(col, useCamelCase, enumNames, domainNames, checkOverride, genericTypeName) {
172
172
  const colName = useCamelCase ? (0, utils_1.toCamelCase)(col.name) : col.name;
173
173
  const commentSuffix = col.comment ? `.comment('${(0, utils_1.escapeString)(col.comment)}')` : '';
174
174
  let line;
@@ -183,15 +183,20 @@ function generateColumnCode(col, useCamelCase, enumNames, domainNames, checkOver
183
183
  }
184
184
  else {
185
185
  let typeBuilder;
186
+ const isJsonType = normalizedType === 'json' || normalizedType === 'jsonb';
187
+ const genericSuffix = isJsonType && genericTypeName ? `<${genericTypeName}>` : '';
186
188
  if (useCamelCase && colName !== col.name) {
187
189
  const builderInfo = (0, type_map_1.getColumnBuilderWithInfo)(col.type, col.typeParams);
188
- typeBuilder = `${builderInfo.builderName}('${col.name}')`;
190
+ typeBuilder = `${builderInfo.builderName}${genericSuffix}('${col.name}')`;
189
191
  if (builderInfo.length != null) {
190
192
  typeBuilder += `.length(${builderInfo.length})`;
191
193
  }
192
194
  }
193
195
  else {
194
196
  typeBuilder = (0, type_map_1.getColumnBuilder)(col.type, col.typeParams);
197
+ if (genericSuffix) {
198
+ typeBuilder = typeBuilder.replace(/(\w+)\(/, `$1${genericSuffix}(`);
199
+ }
195
200
  }
196
201
  if (col.isArray) {
197
202
  typeBuilder = `${typeBuilder}.array()`;
@@ -447,7 +452,7 @@ function generatePartitionCode(child, partitionType, useCamelCase) {
447
452
  needsSqlImport = true;
448
453
  return ` partition('${childName}').in([sql\`${(0, utils_1.escapeString)(bound)}\`])`;
449
454
  }
450
- function generateTableCode(table, useCamelCase, enumNames, domainNames) {
455
+ function generateTableCode(table, useCamelCase, enumNames, domainNames, columnTypeMap = {}) {
451
456
  const tableName = useCamelCase ? (0, utils_1.toCamelCase)(table.name) : table.name;
452
457
  const parts = [];
453
458
  const tableComment = table.comment ? ` comment: '${(0, utils_1.escapeString)(table.comment)}'` : null;
@@ -499,7 +504,11 @@ function generateTableCode(table, useCamelCase, enumNames, domainNames) {
499
504
  isUnique: col.isUnique || uniqueColumns.has(col.name) || uniqueColumns.has(normalizedColName),
500
505
  };
501
506
  });
502
- const columnLines = updatedColumns.map(col => generateColumnCode(col, useCamelCase, enumNames, domainNames, columnChecks.get(col.name)));
507
+ const columnLines = updatedColumns.map(col => {
508
+ const columnKey = `${table.name}.${col.name}`;
509
+ const genericTypeName = columnTypeMap[columnKey];
510
+ return generateColumnCode(col, useCamelCase, enumNames, domainNames, columnChecks.get(col.name), genericTypeName);
511
+ });
503
512
  const optionParts = [];
504
513
  if (table.isPartitioned && table.partitionType && table.partitionKey?.length) {
505
514
  const partCol = useCamelCase ? (0, utils_1.toCamelCase)(table.partitionKey[0]) : table.partitionKey[0];
@@ -715,7 +724,7 @@ function generateTriggerCode(trigger, useCamelCase, functionNames) {
715
724
  return parts.join('\n');
716
725
  }
717
726
  function generateTypeScriptFromAST(schema, options = {}) {
718
- const { camelCase = true, importPath = 'relq/schema-builder', includeEnums = true, includeDomains = true, includeTables = true, includeFunctions = true, includeTriggers = true, } = options;
727
+ const { camelCase = true, importPath = 'relq/schema-builder', includeEnums = true, includeDomains = true, includeTables = true, includeFunctions = true, includeTriggers = true, columnTypeMap = {}, typesImportPath, } = options;
719
728
  const parts = [];
720
729
  parts.push('/**');
721
730
  parts.push(' * Auto-generated by Relq CLI (AST-based)');
@@ -750,7 +759,7 @@ function generateTypeScriptFromAST(schema, options = {}) {
750
759
  for (const table of sortedTables) {
751
760
  if (table.partitionOf)
752
761
  continue;
753
- const code = generateTableCode(table, camelCase, enumNames, domainNames);
762
+ const code = generateTableCode(table, camelCase, enumNames, domainNames, columnTypeMap);
754
763
  tableCodeParts.push(code);
755
764
  for (const col of table.columns) {
756
765
  if (enumNames.has(col.type.toLowerCase())) {
@@ -807,6 +816,11 @@ function generateTypeScriptFromAST(schema, options = {}) {
807
816
  parts.push(` ${imports.join(',\n ')},`);
808
817
  parts.push(` type RelqDatabaseSchema,`);
809
818
  parts.push(`} from '${importPath}';`);
819
+ const usedTypeNames = new Set(Object.values(columnTypeMap));
820
+ if (usedTypeNames.size > 0 && typesImportPath) {
821
+ const typeNames = Array.from(usedTypeNames).sort();
822
+ parts.push(`import { ${typeNames.join(', ')} } from '${typesImportPath}';`);
823
+ }
810
824
  parts.push('');
811
825
  if (needsPgExtensions) {
812
826
  parts.push('// =============================================================================');
@@ -861,7 +875,7 @@ function generateTypeScriptFromAST(schema, options = {}) {
861
875
  for (const table of sortedTables) {
862
876
  if (table.partitionOf)
863
877
  continue;
864
- parts.push(generateTableCode(table, camelCase, enumNames, domainNames));
878
+ parts.push(generateTableCode(table, camelCase, enumNames, domainNames, columnTypeMap));
865
879
  parts.push('');
866
880
  }
867
881
  }
@@ -43,6 +43,10 @@ exports.storeTypesInDb = storeTypesInDb;
43
43
  exports.removeStaleTypes = removeStaleTypes;
44
44
  exports.generateTypesFile = generateTypesFile;
45
45
  exports.validateTypesUsage = validateTypesUsage;
46
+ exports.validateSchemaForInlineTypes = validateSchemaForInlineTypes;
47
+ exports.validateTypeImports = validateTypeImports;
48
+ exports.validateTypesFileSyntax = validateTypesFileSyntax;
49
+ exports.validateTypesConfiguration = validateTypesConfiguration;
46
50
  exports.isValidTypesFilePath = isValidTypesFilePath;
47
51
  exports.getTypesFilePath = getTypesFilePath;
48
52
  exports.syncTypesToDb = syncTypesToDb;
@@ -207,7 +211,6 @@ function extractTypeUsages(schemaContent) {
207
211
  const tablePattern = /(?:(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*)?defineTable\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{/g;
208
212
  let tableMatch;
209
213
  while ((tableMatch = tablePattern.exec(schemaContent)) !== null) {
210
- const varName = tableMatch[1];
211
214
  const tableName = tableMatch[2];
212
215
  const tableStartIdx = tableMatch.index + tableMatch[0].length;
213
216
  let depth = 1;
@@ -220,13 +223,15 @@ function extractTypeUsages(schemaContent) {
220
223
  columnsEndIdx = i;
221
224
  }
222
225
  const columnsBlock = schemaContent.substring(tableStartIdx, columnsEndIdx);
223
- const columnPattern = /(\w+)\s*:\s*(?:jsonb|json|varchar|text|char)\s*<\s*([A-Z][a-zA-Z0-9_]*)/g;
226
+ const columnPattern = /(\w+)\s*:\s*(?:jsonb|json|varchar|text|char)\s*<\s*([A-Z][a-zA-Z0-9_]*)\s*>\s*\(([^)]*)\)/g;
224
227
  let columnMatch;
225
228
  while ((columnMatch = columnPattern.exec(columnsBlock)) !== null) {
226
- const columnName = columnMatch[1];
229
+ const tsColumnName = columnMatch[1];
227
230
  const typeName = columnMatch[2];
228
- const tableRef = varName || tableName;
229
- const usage = `${tableRef}.${columnName}`;
231
+ const argsBlock = columnMatch[3];
232
+ const explicitNameMatch = argsBlock.match(/^\s*['"]([^'"]+)['"]/);
233
+ const sqlColumnName = explicitNameMatch ? explicitNameMatch[1] : tsColumnName;
234
+ const usage = `${tableName}.${sqlColumnName}`;
230
235
  if (!usages[typeName]) {
231
236
  usages[typeName] = [];
232
237
  }
@@ -360,6 +365,174 @@ function validateTypesUsage(usedTypes, definedTypes) {
360
365
  missingTypes,
361
366
  };
362
367
  }
368
+ function validateSchemaForInlineTypes(schemaContent) {
369
+ const lines = schemaContent.split('\n');
370
+ const inlineTypes = [];
371
+ const excludePatterns = [
372
+ /RelqDatabaseSchema/,
373
+ /typeof\s+schema/,
374
+ /Infer</,
375
+ ];
376
+ for (let i = 0; i < lines.length; i++) {
377
+ const line = lines[i];
378
+ const interfaceMatch = line.match(/^(?:export\s+)?interface\s+(\w+)\s*(?:<[^>]*>)?\s*\{/);
379
+ if (interfaceMatch) {
380
+ let hasBody = false;
381
+ for (let j = i + 1; j < lines.length && j < i + 50; j++) {
382
+ const bodyLine = lines[j].trim();
383
+ if (bodyLine === '}')
384
+ break;
385
+ if (bodyLine && !bodyLine.startsWith('//') && !bodyLine.startsWith('/*')) {
386
+ hasBody = true;
387
+ break;
388
+ }
389
+ }
390
+ if (hasBody) {
391
+ inlineTypes.push(`interface ${interfaceMatch[1]} (line ${i + 1})`);
392
+ }
393
+ continue;
394
+ }
395
+ const typeMatch = line.match(/^(?:export\s+)?type\s+(\w+)(?:<[^>]*>)?\s*=\s*(.+)/);
396
+ if (typeMatch) {
397
+ const typeName = typeMatch[1];
398
+ const typeValue = typeMatch[2];
399
+ const isExcluded = excludePatterns.some(pattern => pattern.test(typeValue));
400
+ if (isExcluded) {
401
+ continue;
402
+ }
403
+ const isObjectType = typeValue.trim().startsWith('{');
404
+ const isUnionType = typeValue.includes('|') && !typeValue.includes('<');
405
+ const isLiteralType = /^['"]/.test(typeValue.trim());
406
+ if (isObjectType || isUnionType || isLiteralType) {
407
+ inlineTypes.push(`type ${typeName} (line ${i + 1})`);
408
+ }
409
+ }
410
+ }
411
+ if (inlineTypes.length > 0) {
412
+ return {
413
+ type: 'inline_type',
414
+ message: 'Type definitions found in schema file',
415
+ hint: 'Move type definitions to schema.types.ts to enable database syncing and team collaboration.',
416
+ details: inlineTypes,
417
+ };
418
+ }
419
+ return null;
420
+ }
421
+ function validateTypeImports(schemaContent, schemaPath) {
422
+ const typesFileName = path.basename(getTypesFilePath(schemaPath));
423
+ const wrongImports = [];
424
+ const importPattern = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
425
+ let match;
426
+ while ((match = importPattern.exec(schemaContent)) !== null) {
427
+ const imports = match[1];
428
+ const source = match[2];
429
+ if (source.endsWith('.types') || source.includes(typesFileName.replace('.ts', ''))) {
430
+ continue;
431
+ }
432
+ if (source.startsWith('relq') || source.startsWith('@') || !source.startsWith('.')) {
433
+ continue;
434
+ }
435
+ const importedItems = imports.split(',').map(s => s.trim());
436
+ const typeImports = importedItems.filter(item => {
437
+ const name = item.split(' as ')[0].trim();
438
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name);
439
+ });
440
+ if (typeImports.length > 0) {
441
+ wrongImports.push(`${typeImports.join(', ')} from '${source}'`);
442
+ }
443
+ }
444
+ if (wrongImports.length > 0) {
445
+ return {
446
+ type: 'wrong_import',
447
+ message: 'Types imported from incorrect location',
448
+ hint: `Database-synced types must be defined in ${typesFileName}. Move these type definitions there and update the import.`,
449
+ details: wrongImports,
450
+ };
451
+ }
452
+ return null;
453
+ }
454
+ async function validateTypesFileSyntax(typesFilePath) {
455
+ if (!fs.existsSync(typesFilePath)) {
456
+ return null;
457
+ }
458
+ const content = fs.readFileSync(typesFilePath, 'utf-8');
459
+ const errors = [];
460
+ let braceCount = 0;
461
+ let inInterface = false;
462
+ const lines = content.split('\n');
463
+ for (let i = 0; i < lines.length; i++) {
464
+ const line = lines[i];
465
+ if (/^(?:export\s+)?interface\s+\w+/.test(line)) {
466
+ inInterface = true;
467
+ }
468
+ for (const char of line) {
469
+ if (char === '{')
470
+ braceCount++;
471
+ else if (char === '}')
472
+ braceCount--;
473
+ }
474
+ if (inInterface && braceCount === 0) {
475
+ inInterface = false;
476
+ }
477
+ }
478
+ if (braceCount !== 0) {
479
+ errors.push('Unbalanced braces detected - check for missing { or }');
480
+ }
481
+ const invalidPatterns = [
482
+ { pattern: /interface\s+\d/, message: 'Interface name cannot start with a number' },
483
+ { pattern: /type\s+\d/, message: 'Type name cannot start with a number' },
484
+ { pattern: /:\s*;/, message: 'Missing type annotation before semicolon' },
485
+ ];
486
+ for (const { pattern, message } of invalidPatterns) {
487
+ if (pattern.test(content)) {
488
+ errors.push(message);
489
+ }
490
+ }
491
+ if (errors.length > 0) {
492
+ return {
493
+ type: 'typescript_error',
494
+ message: 'TypeScript syntax errors in types file',
495
+ hint: 'Fix the syntax errors in your types file before pushing.',
496
+ details: errors,
497
+ };
498
+ }
499
+ return null;
500
+ }
501
+ async function validateTypesConfiguration(schemaPath, typesFilePath) {
502
+ const errors = [];
503
+ if (!fs.existsSync(schemaPath)) {
504
+ return errors;
505
+ }
506
+ const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
507
+ const inlineError = validateSchemaForInlineTypes(schemaContent);
508
+ if (inlineError) {
509
+ errors.push(inlineError);
510
+ }
511
+ const importError = validateTypeImports(schemaContent, schemaPath);
512
+ if (importError) {
513
+ errors.push(importError);
514
+ }
515
+ const syntaxError = await validateTypesFileSyntax(typesFilePath);
516
+ if (syntaxError) {
517
+ errors.push(syntaxError);
518
+ }
519
+ if (fs.existsSync(typesFilePath)) {
520
+ const typesContent = fs.readFileSync(typesFilePath, 'utf-8');
521
+ const usedTypes = extractUsedTypes(schemaContent);
522
+ const parsedTypes = parseTypesFile(typesContent);
523
+ const definedTypeNames = parsedTypes.map(t => t.name);
524
+ const usageValidation = validateTypesUsage(usedTypes, definedTypeNames);
525
+ if (!usageValidation.valid) {
526
+ errors.push({
527
+ type: 'missing_type',
528
+ message: 'Types used in schema but not defined in types file',
529
+ hint: `Add these type definitions to ${path.basename(typesFilePath)}.`,
530
+ details: usageValidation.missingTypes,
531
+ });
532
+ }
533
+ }
534
+ return errors;
535
+ }
363
536
  function isValidTypesFilePath(filePath) {
364
537
  const basename = path.basename(filePath);
365
538
  return basename.endsWith('.types.ts');
@@ -4,7 +4,7 @@ import { requireValidConfig, getSchemaPath } from "../utils/config-loader.js";
4
4
  import { fastIntrospectDatabase } from "../utils/fast-introspect.js";
5
5
  import { introspectedToParsedSchema } from "../utils/ast-transformer.js";
6
6
  import { generateTypeScriptFromAST, assignTrackingIds, copyTrackingIdsToNormalized, generateFunctionsFile, generateTriggersFile } from "../utils/ast-codegen.js";
7
- import { syncTypesFromDb, getTypesFilePath } from "../utils/types-manager.js";
7
+ import { syncTypesFromDb, getTypesFilePath, getTypesFromDb } from "../utils/types-manager.js";
8
8
  import { getConnectionDescription } from "../utils/env-loader.js";
9
9
  import { createSpinner, colors, formatBytes, formatDuration, fatal, confirm, warning, createMultiProgress } from "../utils/cli-utils.js";
10
10
  import { loadRelqignore, isTableIgnored, isColumnIgnored, isIndexIgnored, isConstraintIgnored, isEnumIgnored, isDomainIgnored, isCompositeTypeIgnored, isFunctionIgnored, } from "../utils/relqignore.js";
@@ -222,6 +222,7 @@ export async function pullCommand(context) {
222
222
  { id: 'collations', label: 'collations', status: 'pending', count: 0 },
223
223
  { id: 'foreign_servers', label: 'foreign servers', status: 'pending', count: 0 },
224
224
  { id: 'foreign_tables', label: 'foreign tables', status: 'pending', count: 0 },
225
+ { id: 'types', label: 'types', status: 'pending', count: 0 },
225
226
  ];
226
227
  progress.setItems(progressItems);
227
228
  progress.start();
@@ -232,6 +233,9 @@ export async function pullCommand(context) {
232
233
  progress.updateItem(update.step, { status: update.status, count: update.count });
233
234
  },
234
235
  });
236
+ progress.updateItem('types', { status: 'fetching', count: 0 });
237
+ const typesForProgress = await getTypesFromDb(connection);
238
+ progress.updateItem('types', { status: 'done', count: typesForProgress.length });
235
239
  progress.complete();
236
240
  console.log('');
237
241
  const ignorePatterns = loadRelqignore(projectRoot);
@@ -527,6 +531,38 @@ export async function pullCommand(context) {
527
531
  console.log(`Synced ${typesResult.typesCount} type(s) from remote`);
528
532
  hasSynced = true;
529
533
  }
534
+ if (typesForProgress.length > 0 && fs.existsSync(schemaPath)) {
535
+ const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
536
+ const schemaBaseName = path.basename(schemaPath, '.ts');
537
+ const typesImportPattern = new RegExp(`from\\s+['"]\\./${schemaBaseName}\\.types['"]`);
538
+ const hasTypesImport = typesImportPattern.test(schemaContent);
539
+ const typesWithUsages = typesForProgress.filter(t => t.usages && t.usages.length > 0);
540
+ if (typesWithUsages.length > 0 && !hasTypesImport) {
541
+ console.log(`Applying ${typesWithUsages.length} type(s) to schema...`);
542
+ const columnTypeMap = {};
543
+ for (const typeDef of typesForProgress) {
544
+ if (typeDef.usages) {
545
+ for (const usage of typeDef.usages) {
546
+ columnTypeMap[usage] = typeDef.name;
547
+ }
548
+ }
549
+ }
550
+ const parsedSchema = await introspectedToParsedSchema(dbSchema);
551
+ assignTrackingIds(parsedSchema);
552
+ const typesImportPath = `./${schemaBaseName}.types`;
553
+ const typescript = generateTypeScriptFromAST(parsedSchema, {
554
+ camelCase: config.generate?.camelCase ?? true,
555
+ importPath: 'relq/schema-builder',
556
+ includeFunctions: false,
557
+ includeTriggers: false,
558
+ columnTypeMap,
559
+ typesImportPath,
560
+ });
561
+ fs.writeFileSync(schemaPath, typescript, 'utf-8');
562
+ console.log(`Updated ${colors.cyan(schemaPath)} with type annotations`);
563
+ hasSynced = true;
564
+ }
565
+ }
530
566
  if (!hasSynced) {
531
567
  console.log('Already up to date with remote');
532
568
  }
@@ -646,11 +682,23 @@ export async function pullCommand(context) {
646
682
  spinner.start('Generating TypeScript schema...');
647
683
  const parsedSchema = await introspectedToParsedSchema(dbSchema);
648
684
  assignTrackingIds(parsedSchema);
685
+ const columnTypeMap = {};
686
+ for (const typeDef of typesForProgress) {
687
+ if (typeDef.usages) {
688
+ for (const usage of typeDef.usages) {
689
+ columnTypeMap[usage] = typeDef.name;
690
+ }
691
+ }
692
+ }
693
+ const schemaBaseName = path.basename(schemaPath, '.ts');
694
+ const typesImportPath = `./${schemaBaseName}.types`;
649
695
  const typescript = generateTypeScriptFromAST(parsedSchema, {
650
696
  camelCase: config.generate?.camelCase ?? true,
651
697
  importPath: 'relq/schema-builder',
652
698
  includeFunctions: false,
653
699
  includeTriggers: false,
700
+ columnTypeMap,
701
+ typesImportPath,
654
702
  });
655
703
  spinner.succeed('Generated TypeScript schema');
656
704
  const schemaDir = path.dirname(schemaPath);
@@ -6,7 +6,7 @@ import { colors, createSpinner, fatal, confirm, warning } from "../utils/cli-uti
6
6
  import { fastIntrospectDatabase } from "../utils/fast-introspect.js";
7
7
  import { loadRelqignore, isTableIgnored, isColumnIgnored, isEnumIgnored, isDomainIgnored, isFunctionIgnored, } from "../utils/relqignore.js";
8
8
  import { isInitialized, getHead, shortHash, fetchRemoteCommits, pushCommit, ensureRemoteTable, getAllCommits, loadSnapshot, isCommitSyncedWith, markCommitAsPushed, markCommitAsApplied, getConnectionLabel, } from "../utils/repo-manager.js";
9
- import { syncTypesToDb, getTypesFilePath } from "../utils/types-manager.js";
9
+ import { syncTypesToDb, getTypesFilePath, validateTypesConfiguration } from "../utils/types-manager.js";
10
10
  import { getSchemaPath } from "../utils/config-loader.js";
11
11
  export async function pushCommand(context) {
12
12
  const { config, flags } = context;
@@ -115,6 +115,28 @@ export async function pushCommand(context) {
115
115
  const schemaPath = getSchemaPath(config);
116
116
  const typesFilePath = getTypesFilePath(schemaPath);
117
117
  let typesSynced = false;
118
+ if (fs.existsSync(schemaPath)) {
119
+ spinner.start('Validating types configuration...');
120
+ const validationErrors = await validateTypesConfiguration(schemaPath, typesFilePath);
121
+ if (validationErrors.length > 0) {
122
+ spinner.fail('Types validation failed');
123
+ console.log('');
124
+ for (const err of validationErrors) {
125
+ console.log(`${colors.red('error:')} ${err.message}`);
126
+ if (err.details && err.details.length > 0) {
127
+ for (const detail of err.details) {
128
+ console.log(` ${colors.muted('→')} ${detail}`);
129
+ }
130
+ }
131
+ if (err.hint) {
132
+ console.log(` ${colors.cyan('hint:')} ${err.hint}`);
133
+ }
134
+ console.log('');
135
+ }
136
+ fatal('Types configuration is invalid', `Fix the issues above and try again.\nTypes must be defined in ${colors.cyan(path.basename(typesFilePath))} to enable database syncing.`);
137
+ }
138
+ spinner.succeed('Types configuration valid');
139
+ }
118
140
  if (fs.existsSync(typesFilePath) && !dryRun) {
119
141
  spinner.start('Syncing TypeScript types...');
120
142
  const typesResult = await syncTypesToDb(connection, typesFilePath, schemaPath);
@@ -160,7 +160,7 @@ function getExplicitFKName(constraintName, tableName, columnName) {
160
160
  }
161
161
  return constraintName;
162
162
  }
163
- function generateColumnCode(col, useCamelCase, enumNames, domainNames, checkOverride) {
163
+ function generateColumnCode(col, useCamelCase, enumNames, domainNames, checkOverride, genericTypeName) {
164
164
  const colName = useCamelCase ? toCamelCase(col.name) : col.name;
165
165
  const commentSuffix = col.comment ? `.comment('${escapeString(col.comment)}')` : '';
166
166
  let line;
@@ -175,15 +175,20 @@ function generateColumnCode(col, useCamelCase, enumNames, domainNames, checkOver
175
175
  }
176
176
  else {
177
177
  let typeBuilder;
178
+ const isJsonType = normalizedType === 'json' || normalizedType === 'jsonb';
179
+ const genericSuffix = isJsonType && genericTypeName ? `<${genericTypeName}>` : '';
178
180
  if (useCamelCase && colName !== col.name) {
179
181
  const builderInfo = getColumnBuilderWithInfo(col.type, col.typeParams);
180
- typeBuilder = `${builderInfo.builderName}('${col.name}')`;
182
+ typeBuilder = `${builderInfo.builderName}${genericSuffix}('${col.name}')`;
181
183
  if (builderInfo.length != null) {
182
184
  typeBuilder += `.length(${builderInfo.length})`;
183
185
  }
184
186
  }
185
187
  else {
186
188
  typeBuilder = getColumnBuilder(col.type, col.typeParams);
189
+ if (genericSuffix) {
190
+ typeBuilder = typeBuilder.replace(/(\w+)\(/, `$1${genericSuffix}(`);
191
+ }
187
192
  }
188
193
  if (col.isArray) {
189
194
  typeBuilder = `${typeBuilder}.array()`;
@@ -439,7 +444,7 @@ function generatePartitionCode(child, partitionType, useCamelCase) {
439
444
  needsSqlImport = true;
440
445
  return ` partition('${childName}').in([sql\`${escapeString(bound)}\`])`;
441
446
  }
442
- function generateTableCode(table, useCamelCase, enumNames, domainNames) {
447
+ function generateTableCode(table, useCamelCase, enumNames, domainNames, columnTypeMap = {}) {
443
448
  const tableName = useCamelCase ? toCamelCase(table.name) : table.name;
444
449
  const parts = [];
445
450
  const tableComment = table.comment ? ` comment: '${escapeString(table.comment)}'` : null;
@@ -491,7 +496,11 @@ function generateTableCode(table, useCamelCase, enumNames, domainNames) {
491
496
  isUnique: col.isUnique || uniqueColumns.has(col.name) || uniqueColumns.has(normalizedColName),
492
497
  };
493
498
  });
494
- const columnLines = updatedColumns.map(col => generateColumnCode(col, useCamelCase, enumNames, domainNames, columnChecks.get(col.name)));
499
+ const columnLines = updatedColumns.map(col => {
500
+ const columnKey = `${table.name}.${col.name}`;
501
+ const genericTypeName = columnTypeMap[columnKey];
502
+ return generateColumnCode(col, useCamelCase, enumNames, domainNames, columnChecks.get(col.name), genericTypeName);
503
+ });
495
504
  const optionParts = [];
496
505
  if (table.isPartitioned && table.partitionType && table.partitionKey?.length) {
497
506
  const partCol = useCamelCase ? toCamelCase(table.partitionKey[0]) : table.partitionKey[0];
@@ -707,7 +716,7 @@ function generateTriggerCode(trigger, useCamelCase, functionNames) {
707
716
  return parts.join('\n');
708
717
  }
709
718
  export function generateTypeScriptFromAST(schema, options = {}) {
710
- const { camelCase = true, importPath = 'relq/schema-builder', includeEnums = true, includeDomains = true, includeTables = true, includeFunctions = true, includeTriggers = true, } = options;
719
+ const { camelCase = true, importPath = 'relq/schema-builder', includeEnums = true, includeDomains = true, includeTables = true, includeFunctions = true, includeTriggers = true, columnTypeMap = {}, typesImportPath, } = options;
711
720
  const parts = [];
712
721
  parts.push('/**');
713
722
  parts.push(' * Auto-generated by Relq CLI (AST-based)');
@@ -742,7 +751,7 @@ export function generateTypeScriptFromAST(schema, options = {}) {
742
751
  for (const table of sortedTables) {
743
752
  if (table.partitionOf)
744
753
  continue;
745
- const code = generateTableCode(table, camelCase, enumNames, domainNames);
754
+ const code = generateTableCode(table, camelCase, enumNames, domainNames, columnTypeMap);
746
755
  tableCodeParts.push(code);
747
756
  for (const col of table.columns) {
748
757
  if (enumNames.has(col.type.toLowerCase())) {
@@ -799,6 +808,11 @@ export function generateTypeScriptFromAST(schema, options = {}) {
799
808
  parts.push(` ${imports.join(',\n ')},`);
800
809
  parts.push(` type RelqDatabaseSchema,`);
801
810
  parts.push(`} from '${importPath}';`);
811
+ const usedTypeNames = new Set(Object.values(columnTypeMap));
812
+ if (usedTypeNames.size > 0 && typesImportPath) {
813
+ const typeNames = Array.from(usedTypeNames).sort();
814
+ parts.push(`import { ${typeNames.join(', ')} } from '${typesImportPath}';`);
815
+ }
802
816
  parts.push('');
803
817
  if (needsPgExtensions) {
804
818
  parts.push('// =============================================================================');
@@ -853,7 +867,7 @@ export function generateTypeScriptFromAST(schema, options = {}) {
853
867
  for (const table of sortedTables) {
854
868
  if (table.partitionOf)
855
869
  continue;
856
- parts.push(generateTableCode(table, camelCase, enumNames, domainNames));
870
+ parts.push(generateTableCode(table, camelCase, enumNames, domainNames, columnTypeMap));
857
871
  parts.push('');
858
872
  }
859
873
  }
@@ -158,7 +158,6 @@ export function extractTypeUsages(schemaContent) {
158
158
  const tablePattern = /(?:(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*)?defineTable\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{/g;
159
159
  let tableMatch;
160
160
  while ((tableMatch = tablePattern.exec(schemaContent)) !== null) {
161
- const varName = tableMatch[1];
162
161
  const tableName = tableMatch[2];
163
162
  const tableStartIdx = tableMatch.index + tableMatch[0].length;
164
163
  let depth = 1;
@@ -171,13 +170,15 @@ export function extractTypeUsages(schemaContent) {
171
170
  columnsEndIdx = i;
172
171
  }
173
172
  const columnsBlock = schemaContent.substring(tableStartIdx, columnsEndIdx);
174
- const columnPattern = /(\w+)\s*:\s*(?:jsonb|json|varchar|text|char)\s*<\s*([A-Z][a-zA-Z0-9_]*)/g;
173
+ const columnPattern = /(\w+)\s*:\s*(?:jsonb|json|varchar|text|char)\s*<\s*([A-Z][a-zA-Z0-9_]*)\s*>\s*\(([^)]*)\)/g;
175
174
  let columnMatch;
176
175
  while ((columnMatch = columnPattern.exec(columnsBlock)) !== null) {
177
- const columnName = columnMatch[1];
176
+ const tsColumnName = columnMatch[1];
178
177
  const typeName = columnMatch[2];
179
- const tableRef = varName || tableName;
180
- const usage = `${tableRef}.${columnName}`;
178
+ const argsBlock = columnMatch[3];
179
+ const explicitNameMatch = argsBlock.match(/^\s*['"]([^'"]+)['"]/);
180
+ const sqlColumnName = explicitNameMatch ? explicitNameMatch[1] : tsColumnName;
181
+ const usage = `${tableName}.${sqlColumnName}`;
181
182
  if (!usages[typeName]) {
182
183
  usages[typeName] = [];
183
184
  }
@@ -311,6 +312,174 @@ export function validateTypesUsage(usedTypes, definedTypes) {
311
312
  missingTypes,
312
313
  };
313
314
  }
315
+ export function validateSchemaForInlineTypes(schemaContent) {
316
+ const lines = schemaContent.split('\n');
317
+ const inlineTypes = [];
318
+ const excludePatterns = [
319
+ /RelqDatabaseSchema/,
320
+ /typeof\s+schema/,
321
+ /Infer</,
322
+ ];
323
+ for (let i = 0; i < lines.length; i++) {
324
+ const line = lines[i];
325
+ const interfaceMatch = line.match(/^(?:export\s+)?interface\s+(\w+)\s*(?:<[^>]*>)?\s*\{/);
326
+ if (interfaceMatch) {
327
+ let hasBody = false;
328
+ for (let j = i + 1; j < lines.length && j < i + 50; j++) {
329
+ const bodyLine = lines[j].trim();
330
+ if (bodyLine === '}')
331
+ break;
332
+ if (bodyLine && !bodyLine.startsWith('//') && !bodyLine.startsWith('/*')) {
333
+ hasBody = true;
334
+ break;
335
+ }
336
+ }
337
+ if (hasBody) {
338
+ inlineTypes.push(`interface ${interfaceMatch[1]} (line ${i + 1})`);
339
+ }
340
+ continue;
341
+ }
342
+ const typeMatch = line.match(/^(?:export\s+)?type\s+(\w+)(?:<[^>]*>)?\s*=\s*(.+)/);
343
+ if (typeMatch) {
344
+ const typeName = typeMatch[1];
345
+ const typeValue = typeMatch[2];
346
+ const isExcluded = excludePatterns.some(pattern => pattern.test(typeValue));
347
+ if (isExcluded) {
348
+ continue;
349
+ }
350
+ const isObjectType = typeValue.trim().startsWith('{');
351
+ const isUnionType = typeValue.includes('|') && !typeValue.includes('<');
352
+ const isLiteralType = /^['"]/.test(typeValue.trim());
353
+ if (isObjectType || isUnionType || isLiteralType) {
354
+ inlineTypes.push(`type ${typeName} (line ${i + 1})`);
355
+ }
356
+ }
357
+ }
358
+ if (inlineTypes.length > 0) {
359
+ return {
360
+ type: 'inline_type',
361
+ message: 'Type definitions found in schema file',
362
+ hint: 'Move type definitions to schema.types.ts to enable database syncing and team collaboration.',
363
+ details: inlineTypes,
364
+ };
365
+ }
366
+ return null;
367
+ }
368
+ export function validateTypeImports(schemaContent, schemaPath) {
369
+ const typesFileName = path.basename(getTypesFilePath(schemaPath));
370
+ const wrongImports = [];
371
+ const importPattern = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
372
+ let match;
373
+ while ((match = importPattern.exec(schemaContent)) !== null) {
374
+ const imports = match[1];
375
+ const source = match[2];
376
+ if (source.endsWith('.types') || source.includes(typesFileName.replace('.ts', ''))) {
377
+ continue;
378
+ }
379
+ if (source.startsWith('relq') || source.startsWith('@') || !source.startsWith('.')) {
380
+ continue;
381
+ }
382
+ const importedItems = imports.split(',').map(s => s.trim());
383
+ const typeImports = importedItems.filter(item => {
384
+ const name = item.split(' as ')[0].trim();
385
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name);
386
+ });
387
+ if (typeImports.length > 0) {
388
+ wrongImports.push(`${typeImports.join(', ')} from '${source}'`);
389
+ }
390
+ }
391
+ if (wrongImports.length > 0) {
392
+ return {
393
+ type: 'wrong_import',
394
+ message: 'Types imported from incorrect location',
395
+ hint: `Database-synced types must be defined in ${typesFileName}. Move these type definitions there and update the import.`,
396
+ details: wrongImports,
397
+ };
398
+ }
399
+ return null;
400
+ }
401
+ export async function validateTypesFileSyntax(typesFilePath) {
402
+ if (!fs.existsSync(typesFilePath)) {
403
+ return null;
404
+ }
405
+ const content = fs.readFileSync(typesFilePath, 'utf-8');
406
+ const errors = [];
407
+ let braceCount = 0;
408
+ let inInterface = false;
409
+ const lines = content.split('\n');
410
+ for (let i = 0; i < lines.length; i++) {
411
+ const line = lines[i];
412
+ if (/^(?:export\s+)?interface\s+\w+/.test(line)) {
413
+ inInterface = true;
414
+ }
415
+ for (const char of line) {
416
+ if (char === '{')
417
+ braceCount++;
418
+ else if (char === '}')
419
+ braceCount--;
420
+ }
421
+ if (inInterface && braceCount === 0) {
422
+ inInterface = false;
423
+ }
424
+ }
425
+ if (braceCount !== 0) {
426
+ errors.push('Unbalanced braces detected - check for missing { or }');
427
+ }
428
+ const invalidPatterns = [
429
+ { pattern: /interface\s+\d/, message: 'Interface name cannot start with a number' },
430
+ { pattern: /type\s+\d/, message: 'Type name cannot start with a number' },
431
+ { pattern: /:\s*;/, message: 'Missing type annotation before semicolon' },
432
+ ];
433
+ for (const { pattern, message } of invalidPatterns) {
434
+ if (pattern.test(content)) {
435
+ errors.push(message);
436
+ }
437
+ }
438
+ if (errors.length > 0) {
439
+ return {
440
+ type: 'typescript_error',
441
+ message: 'TypeScript syntax errors in types file',
442
+ hint: 'Fix the syntax errors in your types file before pushing.',
443
+ details: errors,
444
+ };
445
+ }
446
+ return null;
447
+ }
448
+ export async function validateTypesConfiguration(schemaPath, typesFilePath) {
449
+ const errors = [];
450
+ if (!fs.existsSync(schemaPath)) {
451
+ return errors;
452
+ }
453
+ const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
454
+ const inlineError = validateSchemaForInlineTypes(schemaContent);
455
+ if (inlineError) {
456
+ errors.push(inlineError);
457
+ }
458
+ const importError = validateTypeImports(schemaContent, schemaPath);
459
+ if (importError) {
460
+ errors.push(importError);
461
+ }
462
+ const syntaxError = await validateTypesFileSyntax(typesFilePath);
463
+ if (syntaxError) {
464
+ errors.push(syntaxError);
465
+ }
466
+ if (fs.existsSync(typesFilePath)) {
467
+ const typesContent = fs.readFileSync(typesFilePath, 'utf-8');
468
+ const usedTypes = extractUsedTypes(schemaContent);
469
+ const parsedTypes = parseTypesFile(typesContent);
470
+ const definedTypeNames = parsedTypes.map(t => t.name);
471
+ const usageValidation = validateTypesUsage(usedTypes, definedTypeNames);
472
+ if (!usageValidation.valid) {
473
+ errors.push({
474
+ type: 'missing_type',
475
+ message: 'Types used in schema but not defined in types file',
476
+ hint: `Add these type definitions to ${path.basename(typesFilePath)}.`,
477
+ details: usageValidation.missingTypes,
478
+ });
479
+ }
480
+ }
481
+ return errors;
482
+ }
314
483
  export function isValidTypesFilePath(filePath) {
315
484
  const basename = path.basename(filePath);
316
485
  return basename.endsWith('.types.ts');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relq",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "description": "The Fully-Typed PostgreSQL ORM for TypeScript",
5
5
  "author": "Olajide Mathew O. <olajide.mathew@yuniq.solutions>",
6
6
  "license": "MIT",
@@ -61,5 +61,7 @@
61
61
  "sql-formatter": "^15.6.12",
62
62
  "strip-comments": "^2.0.1"
63
63
  },
64
- "devDependencies": {}
64
+ "devDependencies": {
65
+ "typescript": "^5.9.3"
66
+ }
65
67
  }