relq 1.0.26 → 1.0.28

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.
@@ -453,6 +453,10 @@ async function fastIntrospectDatabase(connection, onProgress, options) {
453
453
  JOIN pg_language l ON p.prolang = l.oid
454
454
  WHERE n.nspname = 'public'
455
455
  AND p.prokind IN ('f', 'a')
456
+ -- Exclude C language functions (typically extension functions like pgcrypto)
457
+ AND l.lanname != 'c'
458
+ -- Exclude internal language functions
459
+ AND l.lanname != 'internal'
456
460
  ORDER BY p.proname;
457
461
  `);
458
462
  functions = functionsResult.rows.map(f => ({
@@ -486,6 +490,10 @@ async function fastIntrospectDatabase(connection, onProgress, options) {
486
490
  WHEN t.tgtype & 16 > 0 THEN 'UPDATE'
487
491
  ELSE 'UNKNOWN'
488
492
  END as event,
493
+ CASE
494
+ WHEN t.tgtype & 1 > 0 THEN 'ROW'
495
+ ELSE 'STATEMENT'
496
+ END as for_each,
489
497
  p.proname as function_name,
490
498
  pg_get_triggerdef(t.oid) as definition,
491
499
  t.tgenabled != 'D' as is_enabled
@@ -495,6 +503,11 @@ async function fastIntrospectDatabase(connection, onProgress, options) {
495
503
  JOIN pg_proc p ON t.tgfoid = p.oid
496
504
  WHERE n.nspname = 'public'
497
505
  AND NOT t.tgisinternal
506
+ -- Exclude triggers on partition child tables
507
+ AND NOT EXISTS (
508
+ SELECT 1 FROM pg_inherits i
509
+ WHERE i.inhrelid = c.oid
510
+ )
498
511
  ORDER BY c.relname, t.tgname;
499
512
  `);
500
513
  triggers = triggersResult.rows.map(t => ({
@@ -502,6 +515,7 @@ async function fastIntrospectDatabase(connection, onProgress, options) {
502
515
  tableName: t.table_name,
503
516
  timing: t.timing,
504
517
  event: t.event,
518
+ forEach: t.for_each,
505
519
  functionName: t.function_name,
506
520
  definition: t.definition || '',
507
521
  isEnabled: t.is_enabled,
@@ -91,6 +91,30 @@ function compareDomains(before, after) {
91
91
  changes.push((0, change_tracker_1.createChange)('DROP', 'DOMAIN', name, domain, null));
92
92
  }
93
93
  }
94
+ for (const [name, afterDomain] of afterMap) {
95
+ const beforeDomain = beforeMap.get(name);
96
+ if (!beforeDomain)
97
+ continue;
98
+ const baseTypeChanged = beforeDomain.baseType !== afterDomain.baseType;
99
+ const notNullChanged = (beforeDomain.isNotNull || false) !== (afterDomain.isNotNull || false);
100
+ const defaultChanged = (beforeDomain.defaultValue || null) !== (afterDomain.defaultValue || null);
101
+ const checkChanged = (beforeDomain.checkExpression || null) !== (afterDomain.checkExpression || null);
102
+ if (baseTypeChanged || notNullChanged || defaultChanged || checkChanged) {
103
+ changes.push((0, change_tracker_1.createChange)('ALTER', 'DOMAIN', name, {
104
+ name: beforeDomain.name,
105
+ baseType: beforeDomain.baseType,
106
+ notNull: beforeDomain.isNotNull,
107
+ default: beforeDomain.defaultValue,
108
+ check: beforeDomain.checkExpression,
109
+ }, {
110
+ name: afterDomain.name,
111
+ baseType: afterDomain.baseType,
112
+ notNull: afterDomain.isNotNull,
113
+ default: afterDomain.defaultValue,
114
+ check: afterDomain.checkExpression,
115
+ }));
116
+ }
117
+ }
94
118
  return changes;
95
119
  }
96
120
  function compareCompositeTypes(before, after) {
@@ -286,7 +286,7 @@ async function introspectDatabase(connection, onProgress, options) {
286
286
  if (includeFunctions) {
287
287
  onProgress?.('fetching_functions');
288
288
  const functionsResult = await pool.query(`
289
- SELECT
289
+ SELECT
290
290
  p.proname as name,
291
291
  n.nspname as schema,
292
292
  pg_get_function_result(p.oid) as return_type,
@@ -300,6 +300,17 @@ async function introspectDatabase(connection, onProgress, options) {
300
300
  JOIN pg_language l ON p.prolang = l.oid
301
301
  WHERE n.nspname = 'public'
302
302
  AND p.prokind IN ('f', 'a')
303
+ -- Exclude functions that belong to extensions (e.g., pgcrypto, uuid-ossp)
304
+ AND NOT EXISTS (
305
+ SELECT 1 FROM pg_depend d
306
+ JOIN pg_extension e ON d.refobjid = e.oid
307
+ WHERE d.objid = p.oid
308
+ AND d.deptype = 'e'
309
+ )
310
+ -- Exclude C language functions (typically extension functions)
311
+ AND l.lanname != 'c'
312
+ -- Exclude internal language functions
313
+ AND l.lanname != 'internal'
303
314
  ORDER BY p.proname;
304
315
  `);
305
316
  functions = functionsResult.rows.map(f => ({
@@ -316,20 +327,24 @@ async function introspectDatabase(connection, onProgress, options) {
316
327
  let triggers = [];
317
328
  if (includeTriggers) {
318
329
  const triggersResult = await pool.query(`
319
- SELECT
330
+ SELECT
320
331
  t.tgname as name,
321
332
  c.relname as table_name,
322
- CASE
333
+ CASE
323
334
  WHEN t.tgtype & 2 > 0 THEN 'BEFORE'
324
335
  WHEN t.tgtype & 64 > 0 THEN 'INSTEAD OF'
325
336
  ELSE 'AFTER'
326
337
  END as timing,
327
- CASE
338
+ CASE
328
339
  WHEN t.tgtype & 4 > 0 THEN 'INSERT'
329
340
  WHEN t.tgtype & 8 > 0 THEN 'DELETE'
330
341
  WHEN t.tgtype & 16 > 0 THEN 'UPDATE'
331
342
  ELSE 'UNKNOWN'
332
343
  END as event,
344
+ CASE
345
+ WHEN t.tgtype & 1 > 0 THEN 'ROW'
346
+ ELSE 'STATEMENT'
347
+ END as for_each,
333
348
  p.proname as function_name,
334
349
  pg_get_triggerdef(t.oid) as definition,
335
350
  t.tgenabled != 'D' as is_enabled
@@ -339,6 +354,11 @@ async function introspectDatabase(connection, onProgress, options) {
339
354
  JOIN pg_proc p ON t.tgfoid = p.oid
340
355
  WHERE n.nspname = 'public'
341
356
  AND NOT t.tgisinternal
357
+ -- Exclude triggers on partition child tables (only show on parent)
358
+ AND NOT EXISTS (
359
+ SELECT 1 FROM pg_inherits i
360
+ WHERE i.inhrelid = c.oid
361
+ )
342
362
  ORDER BY c.relname, t.tgname;
343
363
  `);
344
364
  triggers = triggersResult.rows.map(t => ({
@@ -346,6 +366,7 @@ async function introspectDatabase(connection, onProgress, options) {
346
366
  tableName: t.table_name,
347
367
  timing: t.timing,
348
368
  event: t.event,
369
+ forEach: t.for_each,
349
370
  functionName: t.function_name,
350
371
  definition: t.definition || '',
351
372
  isEnabled: t.is_enabled,
@@ -868,7 +868,9 @@ function parseTriggers(sql) {
868
868
  const tableMatch = body.match(/\bON\s+(\w+)/i);
869
869
  const tableName = tableMatch ? tableMatch[1] : '';
870
870
  const forEachMatch = body.match(/FOR\s+EACH\s+(ROW|STATEMENT)/i);
871
- const forEach = forEachMatch ? forEachMatch[1].toUpperCase() : 'STATEMENT';
871
+ const forEach = forEachMatch
872
+ ? forEachMatch[1].toUpperCase()
873
+ : 'ROW';
872
874
  const whenMatch = body.match(/WHEN\s*\((.+?)\)\s*(?:EXECUTE|$)/is);
873
875
  const condition = whenMatch ? whenMatch[1].trim() : undefined;
874
876
  const refOldMatch = body.match(/REFERENCING\s+.*?OLD\s+TABLE\s+AS\s+(\w+)/i);
@@ -884,6 +886,7 @@ function parseTriggers(sql) {
884
886
  tableName,
885
887
  timing,
886
888
  event: events[0] || 'INSERT',
889
+ forEach,
887
890
  functionName,
888
891
  definition: match[0],
889
892
  isEnabled: true,
@@ -198,5 +198,8 @@ function validateConfig(config) {
198
198
  errors.push('Pool max must be at least 1');
199
199
  }
200
200
  }
201
+ if (config.includeTriggers && !config.includeFunctions) {
202
+ errors.push('Triggers require functions to be enabled. Set includeFunctions: true in your config.');
203
+ }
201
204
  return { valid: errors.length === 0, errors };
202
205
  }
@@ -100,6 +100,10 @@ function pgFunction(name, options) {
100
100
  trackingId: this.$trackingId,
101
101
  };
102
102
  },
103
+ $id(trackingId) {
104
+ this.$trackingId = trackingId;
105
+ return this;
106
+ },
103
107
  };
104
108
  }
105
109
  function isFunctionConfig(value) {
@@ -26,7 +26,7 @@ function getFunctionName(func) {
26
26
  }
27
27
  function generateTriggerSQL(config) {
28
28
  const { $triggerName, $options } = config;
29
- const { on, before, after, insteadOf, updateOf, forEachRow = true, forEachStatement, when, referencing, constraint, deferrable, initially, execute, executeArgs, } = $options;
29
+ const { on, before, after, insteadOf, updateOf, forEach = 'ROW', when, referencing, constraint, deferrable, initially, execute, executeArgs, } = $options;
30
30
  const parts = ['CREATE'];
31
31
  if (constraint) {
32
32
  parts.push('CONSTRAINT');
@@ -74,10 +74,10 @@ function generateTriggerSQL(config) {
74
74
  }
75
75
  parts.push(refParts.join(' '));
76
76
  }
77
- if (forEachStatement) {
77
+ if (forEach === 'STATEMENT') {
78
78
  parts.push('\n FOR EACH STATEMENT');
79
79
  }
80
- else if (forEachRow !== false) {
80
+ else {
81
81
  parts.push('\n FOR EACH ROW');
82
82
  }
83
83
  if (when) {
@@ -119,7 +119,7 @@ function pgTrigger(name, options) {
119
119
  timing = 'BEFORE';
120
120
  events = ['INSERT'];
121
121
  }
122
- const forEach = opts.forEachStatement ? 'STATEMENT' : 'ROW';
122
+ const forEachLevel = opts.forEach || 'ROW';
123
123
  const functionName = typeof opts.execute === 'string'
124
124
  ? opts.execute
125
125
  : opts.execute.$functionName;
@@ -128,7 +128,7 @@ function pgTrigger(name, options) {
128
128
  table: getTableName(opts.on),
129
129
  timing,
130
130
  events,
131
- forEach,
131
+ forEach: forEachLevel,
132
132
  functionName,
133
133
  whenClause: opts.when,
134
134
  isConstraint: opts.constraint ?? false,
@@ -137,6 +137,10 @@ function pgTrigger(name, options) {
137
137
  trackingId: this.$trackingId,
138
138
  };
139
139
  },
140
+ $id(trackingId) {
141
+ this.$trackingId = trackingId;
142
+ return this;
143
+ },
140
144
  };
141
145
  }
142
146
  function isTriggerConfig(value) {
@@ -229,6 +229,7 @@ function normalizedToDbSchema(normalized) {
229
229
  tableName: t.table,
230
230
  event: t.events.join(' OR '),
231
231
  timing: t.timing,
232
+ forEach: t.forEach || 'ROW',
232
233
  functionName: t.functionName,
233
234
  definition: '',
234
235
  isEnabled: t.isEnabled !== false,
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { parseFunctions, parseTriggers, parseComments } from "../utils/sql-parser.js";
4
- import { generateTypeScriptFromAST } from "../utils/ast-codegen.js";
4
+ import { generateTypeScriptFromAST, assignTrackingIds, generateFunctionsFile, generateTriggersFile } from "../utils/ast-codegen.js";
5
5
  import { parseSQL, normalizedToParsedSchema } from "../utils/ast-transformer.js";
6
6
  import { saveSnapshot, loadSnapshot, isInitialized, initRepository, stageChanges, addUnstagedChanges } from "../utils/repo-manager.js";
7
7
  import { compareSchemas } from "../utils/schema-comparator.js";
@@ -206,6 +206,31 @@ export async function importCommand(sqlFilePath, options = {}, projectRoot = pro
206
206
  return;
207
207
  }
208
208
  spinner.start('Generating TypeScript schema');
209
+ const outputPath = options.output || getSchemaPath(config);
210
+ const absoluteOutputPath = path.resolve(projectRoot, outputPath);
211
+ const schemaExists = fs.existsSync(absoluteOutputPath);
212
+ if (schemaExists && (includeFunctions || includeTriggers)) {
213
+ const existingContent = fs.readFileSync(absoluteOutputPath, 'utf-8');
214
+ const hasPgFunction = /\bpgFunction\s*\(/.test(existingContent);
215
+ const hasPgTrigger = /\bpgTrigger\s*\(/.test(existingContent);
216
+ if (hasPgFunction || hasPgTrigger) {
217
+ spinner.stop();
218
+ const items = [];
219
+ if (hasPgFunction)
220
+ items.push('pgFunction');
221
+ if (hasPgTrigger)
222
+ items.push('pgTrigger');
223
+ const schemaBaseName = path.basename(absoluteOutputPath, '.ts');
224
+ fatal(`Existing schema contains ${items.join(' and ')} definitions`, `Functions and triggers should be in separate files:\n` +
225
+ ` ${colors.cyan(`${schemaBaseName}.functions.ts`)} - for functions\n` +
226
+ ` ${colors.cyan(`${schemaBaseName}.triggers.ts`)} - for triggers\n\n` +
227
+ `To migrate:\n` +
228
+ ` 1. Move pgFunction definitions to ${schemaBaseName}.functions.ts\n` +
229
+ ` 2. Move pgTrigger definitions to ${schemaBaseName}.triggers.ts\n` +
230
+ ` 3. Run ${colors.cyan('relq import')} again\n\n` +
231
+ `Or use ${colors.cyan('relq import --force')} to overwrite and regenerate all files.`);
232
+ }
233
+ }
209
234
  spinner.succeed('Generated TypeScript schema');
210
235
  const incomingSchema = convertToNormalizedSchema(filteredSchema, filteredFunctions, triggers);
211
236
  const existingSnapshot = loadSnapshot(projectRoot);
@@ -353,10 +378,11 @@ export async function importCommand(sqlFilePath, options = {}, projectRoot = pro
353
378
  when: t.when,
354
379
  })) : [],
355
380
  });
381
+ assignTrackingIds(astSchema);
356
382
  const finalTypescriptContent = generateTypeScriptFromAST(astSchema, {
357
383
  camelCase: true,
358
- includeFunctions,
359
- includeTriggers,
384
+ includeFunctions: false,
385
+ includeTriggers: false,
360
386
  });
361
387
  if (dryRun) {
362
388
  console.log('');
@@ -373,14 +399,39 @@ export async function importCommand(sqlFilePath, options = {}, projectRoot = pro
373
399
  console.log('');
374
400
  return;
375
401
  }
376
- const outputPath = options.output || getSchemaPath(config);
377
- const absoluteOutputPath = path.resolve(projectRoot, outputPath);
378
402
  const outputDir = path.dirname(absoluteOutputPath);
379
403
  if (!fs.existsSync(outputDir)) {
380
404
  fs.mkdirSync(outputDir, { recursive: true });
381
405
  }
382
406
  fs.writeFileSync(absoluteOutputPath, finalTypescriptContent, 'utf-8');
383
407
  console.log(`Written ${colors.cyan(absoluteOutputPath)} ${colors.gray(`(${formatBytes(finalTypescriptContent.length)})`)}`);
408
+ if (includeFunctions && astSchema.functions.length > 0) {
409
+ const schemaBaseName = path.basename(absoluteOutputPath, '.ts');
410
+ const functionsPath = path.join(outputDir, `${schemaBaseName}.functions.ts`);
411
+ const functionsCode = generateFunctionsFile(astSchema, {
412
+ camelCase: true,
413
+ importPath: 'relq/schema-builder',
414
+ schemaImportPath: `./${schemaBaseName}`,
415
+ });
416
+ if (functionsCode) {
417
+ fs.writeFileSync(functionsPath, functionsCode, 'utf-8');
418
+ console.log(`Written ${colors.cyan(functionsPath)} ${colors.gray(`(${formatBytes(functionsCode.length)})`)}`);
419
+ }
420
+ }
421
+ if (includeTriggers && astSchema.triggers.length > 0) {
422
+ const schemaBaseName = path.basename(absoluteOutputPath, '.ts');
423
+ const triggersPath = path.join(outputDir, `${schemaBaseName}.triggers.ts`);
424
+ const triggersCode = generateTriggersFile(astSchema, {
425
+ camelCase: true,
426
+ importPath: 'relq/schema-builder',
427
+ schemaImportPath: `./${schemaBaseName}`,
428
+ functionsImportPath: `./${schemaBaseName}.functions`,
429
+ });
430
+ if (triggersCode) {
431
+ fs.writeFileSync(triggersPath, triggersCode, 'utf-8');
432
+ console.log(`Written ${colors.cyan(triggersPath)} ${colors.gray(`(${formatBytes(triggersCode.length)})`)}`);
433
+ }
434
+ }
384
435
  applyTrackingIdsToSnapshot(finalTypescriptContent, mergedSchema);
385
436
  saveSnapshot(mergedSchema, projectRoot);
386
437
  if (changes.length > 0) {
@@ -624,7 +675,7 @@ function convertToNormalizedSchema(parsed, functions = [], triggers = []) {
624
675
  table: t.tableName,
625
676
  events: [t.event],
626
677
  timing: t.timing,
627
- forEach: 'STATEMENT',
678
+ forEach: (t.forEach || 'ROW'),
628
679
  functionName: t.functionName || '',
629
680
  })),
630
681
  views: regularViews.map(v => ({
@@ -706,6 +757,7 @@ function snapshotToDbSchema(snapshot) {
706
757
  tableName: t.table,
707
758
  timing: t.timing,
708
759
  event: t.events?.[0] || '',
760
+ forEach: (t.forEach || 'ROW'),
709
761
  functionName: t.functionName || '',
710
762
  definition: '',
711
763
  isEnabled: t.isEnabled ?? true,
@@ -809,6 +861,7 @@ function snapshotToDbSchemaForGeneration(snapshot) {
809
861
  tableName: t.table,
810
862
  timing: t.timing,
811
863
  event: t.events?.[0] || '',
864
+ forEach: (t.forEach || 'ROW'),
812
865
  functionName: t.functionName || '',
813
866
  definition: '',
814
867
  isEnabled: t.isEnabled ?? true,
@@ -3,7 +3,7 @@ import * as path from 'path';
3
3
  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
- import { generateTypeScriptFromAST, assignTrackingIds, copyTrackingIdsToNormalized } from "../utils/ast-codegen.js";
6
+ import { generateTypeScriptFromAST, assignTrackingIds, copyTrackingIdsToNormalized, generateFunctionsFile, generateTriggersFile } from "../utils/ast-codegen.js";
7
7
  import { getConnectionDescription } from "../utils/env-loader.js";
8
8
  import { createSpinner, colors, formatBytes, formatDuration, fatal, confirm, warning, createMultiProgress } from "../utils/cli-utils.js";
9
9
  import { loadRelqignore, isTableIgnored, isColumnIgnored, isIndexIgnored, isConstraintIgnored, isEnumIgnored, isDomainIgnored, isCompositeTypeIgnored, isFunctionIgnored, } from "../utils/relqignore.js";
@@ -339,10 +339,154 @@ export async function pullCommand(context) {
339
339
  fatal('You have unresolved merge conflicts', `Use ${colors.cyan('relq resolve')} to see and resolve conflicts\nOr use ${colors.cyan('relq pull --force')} to overwrite local`);
340
340
  }
341
341
  if (schemaExists && localSnapshot && !force) {
342
- const localTables = new Set(localSnapshot.tables.map(t => t.name));
343
- const remoteTables = new Set(currentSchema.tables.map(t => t.name));
344
- const added = [...remoteTables].filter(t => !localTables.has(t));
345
- const removed = [...localTables].filter(t => !remoteTables.has(t));
342
+ const localForCompare = {
343
+ extensions: localSnapshot.extensions?.map(e => e.name) || [],
344
+ enums: localSnapshot.enums || [],
345
+ domains: localSnapshot.domains?.map(d => ({
346
+ name: d.name,
347
+ baseType: d.baseType,
348
+ isNotNull: d.notNull,
349
+ defaultValue: d.default,
350
+ checkExpression: d.check,
351
+ })) || [],
352
+ compositeTypes: localSnapshot.compositeTypes || [],
353
+ sequences: localSnapshot.sequences || [],
354
+ tables: localSnapshot.tables.map(t => ({
355
+ name: t.name,
356
+ schema: t.schema,
357
+ columns: t.columns.map(c => ({
358
+ name: c.name,
359
+ dataType: c.type,
360
+ isNullable: c.nullable,
361
+ defaultValue: c.default,
362
+ isPrimaryKey: c.primaryKey,
363
+ isUnique: c.unique,
364
+ comment: c.comment,
365
+ })),
366
+ indexes: t.indexes.map(i => ({
367
+ name: i.name,
368
+ columns: i.columns,
369
+ isUnique: i.unique,
370
+ type: i.type,
371
+ comment: i.comment,
372
+ })),
373
+ constraints: t.constraints || [],
374
+ isPartitioned: t.isPartitioned,
375
+ partitionType: t.partitionType,
376
+ partitionKey: t.partitionKey,
377
+ comment: t.comment,
378
+ })),
379
+ functions: localSnapshot.functions || [],
380
+ triggers: localSnapshot.triggers || [],
381
+ };
382
+ const remoteForCompare = {
383
+ extensions: dbSchema.extensions || [],
384
+ enums: filteredEnums || [],
385
+ domains: filteredDomains?.map(d => ({
386
+ name: d.name,
387
+ baseType: d.baseType,
388
+ isNotNull: d.isNotNull,
389
+ defaultValue: d.defaultValue,
390
+ checkExpression: d.checkExpression,
391
+ })) || [],
392
+ compositeTypes: filteredCompositeTypes || [],
393
+ sequences: [],
394
+ tables: filteredTables.map(t => ({
395
+ name: t.name,
396
+ schema: t.schema,
397
+ columns: t.columns.map(c => ({
398
+ name: c.name,
399
+ dataType: c.dataType,
400
+ isNullable: c.isNullable,
401
+ defaultValue: c.defaultValue,
402
+ isPrimaryKey: c.isPrimaryKey,
403
+ isUnique: c.isUnique,
404
+ comment: c.comment,
405
+ })),
406
+ indexes: t.indexes.map(i => ({
407
+ name: i.name,
408
+ columns: i.columns,
409
+ isUnique: i.isUnique,
410
+ type: i.type,
411
+ comment: i.comment,
412
+ })),
413
+ constraints: t.constraints || [],
414
+ isPartitioned: t.isPartitioned,
415
+ partitionType: t.partitionType,
416
+ partitionKey: t.partitionKey,
417
+ comment: t.comment,
418
+ })),
419
+ functions: filteredFunctions || [],
420
+ triggers: filteredTriggers || [],
421
+ };
422
+ const allChanges = compareSchemas(localForCompare, remoteForCompare);
423
+ const changeDisplays = [];
424
+ for (const change of allChanges) {
425
+ const objType = change.objectType;
426
+ const changeType = change.type;
427
+ let action;
428
+ if (changeType === 'CREATE')
429
+ action = 'added';
430
+ else if (changeType === 'DROP')
431
+ action = 'removed';
432
+ else
433
+ action = 'modified';
434
+ let type;
435
+ let name;
436
+ if (objType === 'TABLE') {
437
+ type = 'table';
438
+ name = change.objectName;
439
+ }
440
+ else if (objType === 'COLUMN') {
441
+ type = 'column';
442
+ name = change.parentName ? `${change.parentName}.${change.objectName}` : change.objectName;
443
+ }
444
+ else if (objType === 'INDEX') {
445
+ type = 'index';
446
+ name = change.parentName ? `${change.parentName}:${change.objectName}` : change.objectName;
447
+ }
448
+ else if (objType === 'COLUMN_COMMENT') {
449
+ type = 'column comment';
450
+ const colName = change.after?.columnName || change.before?.columnName || change.objectName;
451
+ const tblName = change.after?.tableName || change.before?.tableName || change.parentName;
452
+ name = tblName ? `${tblName}.${colName}` : colName;
453
+ }
454
+ else if (objType === 'TABLE_COMMENT') {
455
+ type = 'table comment';
456
+ name = change.after?.tableName || change.before?.tableName || change.objectName;
457
+ }
458
+ else if (objType === 'INDEX_COMMENT') {
459
+ type = 'index comment';
460
+ const idxName = change.after?.indexName || change.before?.indexName || change.objectName;
461
+ const tblName = change.after?.tableName || change.before?.tableName || change.parentName;
462
+ name = tblName ? `${tblName}:${idxName}` : idxName;
463
+ }
464
+ else if (objType === 'CONSTRAINT' || objType === 'FOREIGN_KEY' || objType === 'PRIMARY_KEY' || objType === 'CHECK') {
465
+ type = objType.toLowerCase().replace(/_/g, ' ');
466
+ name = change.parentName ? `${change.parentName}::${change.objectName}` : change.objectName;
467
+ }
468
+ else if (objType === 'ENUM') {
469
+ type = 'enum';
470
+ name = change.objectName;
471
+ }
472
+ else if (objType === 'DOMAIN') {
473
+ type = 'domain';
474
+ name = change.objectName;
475
+ }
476
+ else if (objType === 'FUNCTION') {
477
+ type = 'function';
478
+ name = change.objectName;
479
+ }
480
+ else if (objType === 'TRIGGER') {
481
+ type = 'trigger';
482
+ name = change.objectName;
483
+ }
484
+ else {
485
+ type = objType.toLowerCase().replace(/_/g, ' ');
486
+ name = change.objectName;
487
+ }
488
+ changeDisplays.push({ action, type, name });
489
+ }
346
490
  const conflicts = detectObjectConflicts(localSnapshot, currentSchema);
347
491
  if (conflicts.length > 0 && !force) {
348
492
  const mergeState = {
@@ -364,17 +508,27 @@ export async function pullCommand(context) {
364
508
  }
365
509
  fatal('Automatic merge failed; fix conflicts and then commit', `${colors.cyan('relq resolve --theirs <name>')} Take remote version\n${colors.cyan('relq resolve --all-theirs')} Take all remote\n${colors.cyan('relq pull --force')} Force overwrite local`);
366
510
  }
367
- if (added.length === 0 && removed.length === 0) {
511
+ if (allChanges.length === 0) {
368
512
  console.log('Already up to date with remote');
369
513
  console.log('');
370
514
  return;
371
515
  }
372
516
  console.log(`${colors.yellow('Remote has changes:')}`);
373
- if (added.length > 0) {
374
- console.log(` ${colors.green(`+${added.length}`)} tables added`);
517
+ for (const chg of changeDisplays.slice(0, 15)) {
518
+ let colorFn = colors.cyan;
519
+ let prefix = '~';
520
+ if (chg.action === 'added') {
521
+ colorFn = colors.green;
522
+ prefix = '+';
523
+ }
524
+ else if (chg.action === 'removed') {
525
+ colorFn = colors.red;
526
+ prefix = '-';
527
+ }
528
+ console.log(` ${colorFn(prefix)} ${chg.type}: ${colors.bold(chg.name)}`);
375
529
  }
376
- if (removed.length > 0) {
377
- console.log(` ${colors.red(`-${removed.length}`)} tables removed`);
530
+ if (changeDisplays.length > 15) {
531
+ console.log(` ${colors.muted(`... and ${changeDisplays.length - 15} more`)}`);
378
532
  }
379
533
  console.log('');
380
534
  const noAutoMerge = flags['no-auto-merge'] === true;
@@ -448,12 +602,35 @@ export async function pullCommand(context) {
448
602
  console.log('');
449
603
  return;
450
604
  }
605
+ if (schemaExists && (includeFunctions || includeTriggers)) {
606
+ const existingContent = fs.readFileSync(schemaPath, 'utf-8');
607
+ const hasPgFunction = /\bpgFunction\s*\(/.test(existingContent);
608
+ const hasPgTrigger = /\bpgTrigger\s*\(/.test(existingContent);
609
+ if (hasPgFunction || hasPgTrigger) {
610
+ const items = [];
611
+ if (hasPgFunction)
612
+ items.push('pgFunction');
613
+ if (hasPgTrigger)
614
+ items.push('pgTrigger');
615
+ const schemaBaseName = path.basename(schemaPath, '.ts');
616
+ fatal(`Existing schema contains ${items.join(' and ')} definitions`, `Functions and triggers should be in separate files:\n` +
617
+ ` ${colors.cyan(`${schemaBaseName}.functions.ts`)} - for functions\n` +
618
+ ` ${colors.cyan(`${schemaBaseName}.triggers.ts`)} - for triggers\n\n` +
619
+ `To migrate:\n` +
620
+ ` 1. Move pgFunction definitions to ${schemaBaseName}.functions.ts\n` +
621
+ ` 2. Move pgTrigger definitions to ${schemaBaseName}.triggers.ts\n` +
622
+ ` 3. Run ${colors.cyan('relq pull')} again\n\n` +
623
+ `Or use ${colors.cyan('relq pull --force')} to overwrite and regenerate all files.`);
624
+ }
625
+ }
451
626
  spinner.start('Generating TypeScript schema...');
452
627
  const parsedSchema = await introspectedToParsedSchema(dbSchema);
453
628
  assignTrackingIds(parsedSchema);
454
629
  const typescript = generateTypeScriptFromAST(parsedSchema, {
455
630
  camelCase: config.generate?.camelCase ?? true,
456
631
  importPath: 'relq/schema-builder',
632
+ includeFunctions: false,
633
+ includeTriggers: false,
457
634
  });
458
635
  spinner.succeed('Generated TypeScript schema');
459
636
  const schemaDir = path.dirname(schemaPath);
@@ -466,6 +643,35 @@ export async function pullCommand(context) {
466
643
  spinner.succeed(`Written ${colors.cyan(schemaPath)} ${colors.muted(`(${formatBytes(fileSize)})`)}`);
467
644
  const fileHash = hashFileContent(typescript);
468
645
  saveFileHash(fileHash, projectRoot);
646
+ if (includeFunctions && parsedSchema.functions.length > 0) {
647
+ const schemaBaseName = path.basename(schemaPath, '.ts');
648
+ const functionsPath = path.join(schemaDir, `${schemaBaseName}.functions.ts`);
649
+ const functionsCode = generateFunctionsFile(parsedSchema, {
650
+ camelCase: config.generate?.camelCase ?? true,
651
+ importPath: 'relq/schema-builder',
652
+ schemaImportPath: `./${schemaBaseName}`,
653
+ });
654
+ if (functionsCode) {
655
+ fs.writeFileSync(functionsPath, functionsCode, 'utf-8');
656
+ const funcFileSize = Buffer.byteLength(functionsCode, 'utf8');
657
+ spinner.succeed(`Written ${colors.cyan(functionsPath)} ${colors.muted(`(${formatBytes(funcFileSize)})`)}`);
658
+ }
659
+ }
660
+ if (includeTriggers && parsedSchema.triggers.length > 0) {
661
+ const schemaBaseName = path.basename(schemaPath, '.ts');
662
+ const triggersPath = path.join(schemaDir, `${schemaBaseName}.triggers.ts`);
663
+ const triggersCode = generateTriggersFile(parsedSchema, {
664
+ camelCase: config.generate?.camelCase ?? true,
665
+ importPath: 'relq/schema-builder',
666
+ schemaImportPath: `./${schemaBaseName}`,
667
+ functionsImportPath: `./${schemaBaseName}.functions`,
668
+ });
669
+ if (triggersCode) {
670
+ fs.writeFileSync(triggersPath, triggersCode, 'utf-8');
671
+ const trigFileSize = Buffer.byteLength(triggersCode, 'utf8');
672
+ spinner.succeed(`Written ${colors.cyan(triggersPath)} ${colors.muted(`(${formatBytes(trigFileSize)})`)}`);
673
+ }
674
+ }
469
675
  const oldSnapshot = loadSnapshot(projectRoot);
470
676
  const beforeSchema = oldSnapshot ? {
471
677
  extensions: oldSnapshot.extensions?.map(e => e.name) || [],
@@ -1,7 +1,7 @@
1
1
  import { requireValidConfig } from "../utils/config-loader.js";
2
2
  import { getConnectionDescription } from "../utils/env-loader.js";
3
3
  import { colors, createSpinner, fatal, success } from "../utils/cli-utils.js";
4
- import { isInitialized, shortHash, fetchRemoteCommits, pushCommit, ensureRemoteTable, getAllCommits, } from "../utils/repo-manager.js";
4
+ import { isInitialized, initRepository, shortHash, fetchRemoteCommits, pushCommit, ensureRemoteTable, getAllCommits, } from "../utils/repo-manager.js";
5
5
  import { pullCommand } from "./pull.js";
6
6
  export async function syncCommand(context) {
7
7
  const { config, flags } = context;
@@ -13,7 +13,10 @@ export async function syncCommand(context) {
13
13
  const { projectRoot } = context;
14
14
  console.log('');
15
15
  if (!isInitialized(projectRoot)) {
16
- fatal('not a relq repository (or any parent directories): .relq', `Run ${colors.cyan('relq init')} to initialize.`);
16
+ console.log(`${colors.yellow('Initializing')} .relq repository...`);
17
+ initRepository(projectRoot);
18
+ console.log(`${colors.green('✓')} Repository initialized`);
19
+ console.log('');
17
20
  }
18
21
  const spinner = createSpinner();
19
22
  try {