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.
- package/dist/cjs/cli/commands/export.cjs +1 -0
- package/dist/cjs/cli/commands/import.cjs +58 -5
- package/dist/cjs/cli/commands/pull.cjs +215 -9
- package/dist/cjs/cli/commands/sync.cjs +4 -1
- package/dist/cjs/cli/utils/ast-codegen.cjs +165 -9
- package/dist/cjs/cli/utils/ast-transformer.cjs +16 -2
- package/dist/cjs/cli/utils/fast-introspect.cjs +14 -0
- package/dist/cjs/cli/utils/schema-comparator.cjs +24 -0
- package/dist/cjs/cli/utils/schema-introspect.cjs +25 -4
- package/dist/cjs/cli/utils/sql-parser.cjs +4 -1
- package/dist/cjs/config/config.cjs +3 -0
- package/dist/cjs/schema-definition/pg-function.cjs +4 -0
- package/dist/cjs/schema-definition/pg-trigger.cjs +9 -5
- package/dist/esm/cli/commands/export.js +1 -0
- package/dist/esm/cli/commands/import.js +59 -6
- package/dist/esm/cli/commands/pull.js +216 -10
- package/dist/esm/cli/commands/sync.js +5 -2
- package/dist/esm/cli/utils/ast-codegen.js +163 -9
- package/dist/esm/cli/utils/ast-transformer.js +16 -2
- package/dist/esm/cli/utils/fast-introspect.js +14 -0
- package/dist/esm/cli/utils/schema-comparator.js +24 -0
- package/dist/esm/cli/utils/schema-introspect.js +25 -4
- package/dist/esm/cli/utils/sql-parser.js +4 -1
- package/dist/esm/config/config.js +3 -0
- package/dist/esm/schema-definition/pg-function.js +4 -0
- package/dist/esm/schema-definition/pg-trigger.js +9 -5
- package/dist/schema-builder.d.ts +25 -19
- package/package.json +1 -1
|
@@ -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
|
|
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
|
}
|
|
@@ -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,
|
|
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 (
|
|
77
|
+
if (forEach === 'STATEMENT') {
|
|
78
78
|
parts.push('\n FOR EACH STATEMENT');
|
|
79
79
|
}
|
|
80
|
-
else
|
|
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
|
|
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) {
|
|
@@ -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: '
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 (
|
|
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
|
-
|
|
374
|
-
|
|
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 (
|
|
377
|
-
console.log(` ${colors.
|
|
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
|
-
|
|
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 {
|