-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathtransform.ts
More file actions
326 lines (283 loc) · 11.8 KB
/
Copy pathtransform.ts
File metadata and controls
326 lines (283 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import { PgpmMigrate, PgpmPackage, PgpmRow } from '@pgpmjs/core';
import { Logger } from '@pgpmjs/logger';
import {
diffCatalogSnapshots,
EXPORT_GRANULARITIES,
ExportGranularity,
isExportGranularity,
loadModuleSource,
parsePartitionConfig,
PartitionConfig,
PartitionCycleError,
PartitionedPackageRows,
partitionExportRows,
restructureExportRows,
snapshotCatalog
} from '@pgpmjs/transform';
import * as fs from 'fs';
import { cliExitWithError, CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer';
import * as path from 'path';
import { getPgPool } from 'pg-cache';
import type { PgConfig } from 'pg-env';
import { getPgEnvOptions } from 'pg-env';
import { checkOverwrite, writePackage } from '../utils/emit-package';
export { checkOverwrite } from '../utils/emit-package';
const log = new Logger('transform');
const transformUsageText = `
Transform Command:
pgpm transform --granularity <atomic|object|consolidated> [OPTIONS]
Re-dial an existing pgpm module (or every module in a workspace) through the
dials pipeline: flatten the deploy scripts in plan order, re-project them at
the requested granularity with spec-derived change paths, graph-derived
requires, and generated revert/verify scripts.
Options:
--help, -h Show this help message
--granularity <level> Target granularity: atomic | object | consolidated (required)
--partition <file> Partition config (JSON: rules/defaultPackage/splitRiders)
splitting the module into multiple pgpm packages with
derived cross-package requires.
--naming <style> Change path naming style: directory | flat (default: directory)
--cwd <directory> Module or workspace directory (default: current directory)
--out <dir> Output directory (default: sibling <module>-<granularity>)
--write Allow writing over an existing/module directory
--check Deploy original and transformed output into scratch
databases and assert the catalogs are equivalent
--dry-run Print the resulting plan/paths without writing
Examples:
pgpm transform --granularity object
pgpm transform --granularity atomic --naming flat --out ./out
pgpm transform --granularity object --partition partition.json --check
`;
const NAMING_STYLES = ['directory', 'flat'] as const;
type NamingStyle = (typeof NAMING_STYLES)[number];
interface TransformedModule {
/** Source module directory. */
modulePath: string;
/** Module (project) name. */
name: string;
/** Base directory the output package dir(s) are created in. */
outBase: string;
/** Packages to write: one for plain transforms, N when partitioned. */
packages: PartitionedPackageRows[];
warnings: string[];
}
/**
* Resolve the base directory package dir(s) are created in. Without --out,
* packages are written as siblings of the source module — a plain transform
* of `my-mod` at granularity `object` lands in `../my-mod-object`.
*/
export const resolveOutBase = (modulePath: string, out?: string): string => {
if (out) return path.resolve(out);
return path.dirname(modulePath);
};
/** Order partition packages so prerequisites come before dependents. */
export const orderPackages = (packages: PartitionedPackageRows[]): PartitionedPackageRows[] => {
const byName = new Map(packages.map(pkg => [pkg.name, pkg]));
const ordered: PartitionedPackageRows[] = [];
const seen = new Set<string>();
const visit = (pkg: PartitionedPackageRows): void => {
if (seen.has(pkg.name)) return;
seen.add(pkg.name);
for (const req of pkg.requires) {
const dep = byName.get(req);
if (dep) visit(dep);
}
ordered.push(pkg);
};
for (const pkg of packages) visit(pkg);
return ordered;
};
const readControlRequires = (modulePath: string, name: string): string[] => {
const controlPath = path.join(modulePath, `${name}.control`);
if (!fs.existsSync(controlPath)) return [];
const match = fs.readFileSync(controlPath, 'utf-8').match(/^requires\s*=\s*'([^']*)'\s*$/m);
if (!match) return [];
return match[1].split(',').map(s => s.trim()).filter(Boolean);
};
const createScratchDb = async (config: PgConfig, dbName: string): Promise<void> => {
const adminPool = getPgPool({ ...config, database: 'postgres' });
await adminPool.query(`DROP DATABASE IF EXISTS "${dbName}"`);
await adminPool.query(`CREATE DATABASE "${dbName}"`);
};
const dropScratchDb = async (config: PgConfig, dbName: string): Promise<void> => {
const adminPool = getPgPool({ ...config, database: 'postgres' });
await adminPool.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
};
const deployModules = async (config: PgConfig, modulePaths: string[]): Promise<void> => {
const client = new PgpmMigrate(config);
for (const modulePath of modulePaths) {
const result = await client.deploy({ modulePath });
if (result.failed) {
throw new Error(`deploy failed at change ${result.failed} (module ${modulePath})`);
}
}
};
/**
* Prove the transform is structurally lossless: deploy the original module and
* the transformed package(s) into two scratch databases and compare catalogs.
*/
const runCheck = async (transformed: TransformedModule): Promise<string[]> => {
const config = getPgEnvOptions();
const stamp = Date.now();
const dbOriginal = `pgpm_transform_check_a_${stamp}`;
const dbTransformed = `pgpm_transform_check_b_${stamp}`;
try {
await createScratchDb(config, dbOriginal);
await createScratchDb(config, dbTransformed);
await deployModules({ ...config, database: dbOriginal }, [transformed.modulePath]);
const orderedDirs = orderPackages(transformed.packages).map(pkg =>
path.join(transformed.outBase, pkg.name)
);
await deployModules({ ...config, database: dbTransformed }, orderedDirs);
const poolOriginal = getPgPool({ ...config, database: dbOriginal });
const poolTransformed = getPgPool({ ...config, database: dbTransformed });
const snapOriginal = await snapshotCatalog(poolOriginal);
const snapTransformed = await snapshotCatalog(poolTransformed);
return diffCatalogSnapshots(snapOriginal, snapTransformed);
} finally {
try {
await dropScratchDb(config, dbOriginal);
await dropScratchDb(config, dbTransformed);
} catch (err) {
log.warn(`failed to drop scratch databases: ${err instanceof Error ? err.message : err}`);
}
}
};
const transformModule = async (
modulePath: string,
granularity: ExportGranularity,
naming: NamingStyle,
partition: PartitionConfig | undefined,
out: string | undefined
): Promise<TransformedModule> => {
const source = loadModuleSource(modulePath);
const warnings = [...source.warnings];
const rows: PgpmRow[] = source.changes.map(change => ({
name: change.name,
deploy: change.name,
deps: change.dependencies,
content: change.deploy
}));
const restructured = await restructureExportRows(rows, granularity, { naming });
warnings.push(...restructured.warnings.map(w => `restructure (${granularity}): ${w}`));
const outBase = resolveOutBase(modulePath, out);
let packages: PartitionedPackageRows[];
if (partition) {
const result = await partitionExportRows(restructured.rows, partition);
warnings.push(...result.warnings.map(w => `partition: ${w}`));
packages = result.packages;
} else {
// Distinct package name so the output can live beside the source module
// in the same workspace without colliding.
packages = [{ name: `${source.name}-${granularity}`, requires: [], rows: restructured.rows }];
}
return { modulePath, name: source.name, outBase, packages, warnings };
};
const printDryRun = (transformed: TransformedModule): void => {
console.log(`module ${transformed.name} (${transformed.modulePath})`);
for (const pkg of transformed.packages) {
console.log(` package ${pkg.name} -> ${path.join(transformed.outBase, pkg.name)}`);
for (const row of pkg.rows) {
const deps = row.deps?.length ? ` [${row.deps.join(' ')}]` : '';
console.log(` ${row.deploy}${deps}`);
}
}
};
export default async (
argv: Partial<ParsedArgs>,
prompter: Inquirerer,
_options: CLIOptions
) => {
if (argv.help || argv.h) {
console.log(transformUsageText);
process.exit(0);
}
const granularityRaw = argv.granularity;
if (granularityRaw === undefined) {
await cliExitWithError(`--granularity is required. Expected one of: ${EXPORT_GRANULARITIES.join(', ')}.`);
}
if (!isExportGranularity(granularityRaw)) {
await cliExitWithError(`Invalid --granularity "${granularityRaw}". Expected one of: ${EXPORT_GRANULARITIES.join(', ')}.`);
}
const granularity = granularityRaw as ExportGranularity;
const namingRaw = (argv.naming as string) ?? 'directory';
if (!(NAMING_STYLES as readonly string[]).includes(namingRaw)) {
await cliExitWithError(`Invalid --naming "${namingRaw}". Expected one of: ${NAMING_STYLES.join(', ')}.`);
}
const naming = namingRaw as NamingStyle;
const cwd = (argv.cwd as string) || process.cwd();
let partition: PartitionConfig | undefined;
if (typeof argv.partition === 'string' && argv.partition) {
try {
partition = parsePartitionConfig(path.resolve(cwd, argv.partition));
} catch (err) {
await cliExitWithError(err instanceof Error ? err.message : String(err));
}
}
const out = typeof argv.out === 'string' && argv.out ? path.resolve(cwd, argv.out) : undefined;
const write = Boolean(argv.write);
const check = Boolean(argv.check);
const dryRun = Boolean(argv['dry-run'] ?? argv.dryRun);
const pkg = new PgpmPackage(path.resolve(cwd));
let modulePaths: string[];
if (pkg.isInModule()) {
modulePaths = [pkg.modulePath!];
} else if (pkg.workspacePath) {
const moduleMap = pkg.getModuleMap();
modulePaths = Object.values(moduleMap).map(mod =>
path.resolve(pkg.workspacePath!, mod.path)
);
if (modulePaths.length === 0) {
await cliExitWithError('No modules found in workspace.');
}
if (out && modulePaths.length > 1) {
await cliExitWithError('--out is not supported when transforming a multi-module workspace.');
}
} else {
await cliExitWithError('Not inside a pgpm module or workspace (pass --cwd <dir>).');
return;
}
for (const modulePath of modulePaths) {
let transformed: TransformedModule;
try {
transformed = await transformModule(modulePath, granularity, naming, partition, out);
} catch (err) {
if (err instanceof PartitionCycleError) {
await cliExitWithError(`Partition failed: ${err.message}`);
return;
}
throw err;
}
for (const warning of transformed.warnings) {
console.warn(`transform: ${warning}`);
}
if (dryRun) {
printDryRun(transformed);
continue;
}
for (const pkgRows of transformed.packages) {
const targetDir = path.join(transformed.outBase, pkgRows.name);
const guard = checkOverwrite(targetDir, modulePath, write);
if (guard) {
await cliExitWithError(guard);
}
}
const sourceRequires = readControlRequires(modulePath, transformed.name);
for (const pkgRows of transformed.packages) {
const dir = writePackage(transformed.outBase, pkgRows, sourceRequires);
log.success(`wrote ${pkgRows.rows.length} changes to ${dir}`);
}
if (check) {
log.info('running --check: deploying original and transformed output to scratch databases...');
const diffs = await runCheck(transformed);
if (diffs.length) {
console.error(`--check failed: catalogs differ (${diffs.length} differences):`);
for (const diff of diffs) console.error(` ${diff}`);
await cliExitWithError('Transform is not structurally lossless.');
}
log.success('--check passed: catalogs are structurally equivalent.');
}
}
prompter.close();
return argv;
};