uql-codemod 0.23.0

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/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015-present UQL Contributors
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # uql-codemod
2
+
3
+ Migrates [uql-orm](https://uql-orm.dev) entities from the legacy TypeScript decorators to the TC39
4
+ standard spec. One command, on your own source, with a dry run first:
5
+
6
+ ```sh
7
+ npx uql-codemod --project=tsconfig.json --dry-run
8
+ npx uql-codemod --project=tsconfig.json
9
+ ```
10
+
11
+ | Flag | |
12
+ |---|---|
13
+ | `--project=<path>` | `tsconfig.json` to read. Required in practice: the codemod needs a real program, not a parser, because it writes down the types `design:type` used to report at runtime. Defaults to `./tsconfig.json`. |
14
+ | `--dry-run` | Report what would change and touch nothing. |
15
+ | `--include=<a,b>` | Only files whose path contains one of these fragments. |
16
+
17
+ Only the `--flag=value` form is read, and anything unrecognised is an error: a misspelled `--dry-run`
18
+ would otherwise rewrite the project for real. It exits `0` when nothing is left for you, `1` when
19
+ something is, and `2` when it could not start at all - a bad argument, or a project it cannot read.
20
+
21
+ ## What it rewrites
22
+
23
+ - `@Field()` and `@Field({ ... })` with no `type`, and `@Id()`: inserts the `type` the property's
24
+ declared TypeScript type implies. Skipped when `references` is present, because schema generation
25
+ resolves that column from the referenced primary key.
26
+ - Bare relation decorators: inserts `entity: () => X`, unwrapping arrays and the old `Relation<T>` alias.
27
+ - `Relation<T>` becomes `T`, and its import goes with the last usage - `uql-orm` no longer exports it.
28
+ A usage somewhere the codemod does not reach, such as a type alias, keeps the import and is reported.
29
+ - `@Transactional()` methods: drops the `@InjectQuerier() querier?: Querier` parameter and rewrites the
30
+ body to `currentQuerier()`.
31
+ - `import 'reflect-metadata'` goes. Removing the package from your `package.json` is left to you.
32
+ - `tsconfig.json`: removes `experimentalDecorators` and `emitDecoratorMetadata`, keeping the rest of the
33
+ file - comments and formatting included - exactly as written.
34
+
35
+ ## What it refuses to do
36
+
37
+ It reports rather than guesses, and exits non-zero when anything is left for you:
38
+
39
+ - **`target: esnext`** is reported, not changed. Removing the line falls back to the compiler default
40
+ (`es5` for `tsc`), and choosing a replacement means guessing which era the project targets. Any dated
41
+ target works; `esnext` is the one where TypeScript emits decorator syntax untransformed.
42
+ - **A value inherited through `extends`** cannot be edited here, so the base config is reported instead.
43
+ - **`@Log()` and `@Serialized()`** are reported and left in place. They no longer exist, and what to do
44
+ instead is a judgement call.
45
+ - **Options it cannot read** (`@Field(sharedOptions)`, a spread, or a `@Field` never called at all) are
46
+ left exactly as written. Replacing an argument it cannot parse would silently drop the options.
47
+ - **A property whose type it cannot map to a column** (a `Json<T>`, a vector, a union that disagrees with
48
+ itself) is reported as `needs a decision`. Those always had to declare `type` by hand anyway, because
49
+ `design:type` reported the useless `Object`/`Array` for them.
50
+ - **A branded string id**, e.g. `type UUID = \`${string}-${string}\``, gets `type: String` plus a
51
+ `worth a look` note. That is a real fork: `String` generates a text column, `'uuid'` generates a native
52
+ one, and only you know which the database has.
53
+
54
+ ## After it runs
55
+
56
+ `tsc` is the rest of the migration, and that is the point: the annotations the codemod inserts are now
57
+ *checked* against the properties they describe, so anything it got wrong is a compile error rather than a
58
+ silently wrong column. See the [upgrade guide](https://uql-orm.dev/upgrade-guide).
59
+
60
+ ## License
61
+
62
+ MIT
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from './cli.js';
3
+ import { run } from './index.js';
4
+ const USAGE = 'usage: uql-codemod [--project=<path>] [--include=<a,b>] [--dry-run]';
5
+ // Exit 2 is "could not start", so a script can tell it apart from exit 1, "there is work left".
6
+ const stop = (message) => {
7
+ console.error(message);
8
+ process.exit(2);
9
+ };
10
+ const { options, errors } = parseArgs(process.argv.slice(2));
11
+ if (errors.length) {
12
+ stop([...errors, USAGE].join('\n'));
13
+ }
14
+ // A wrong `--project` is the likeliest mistake here, and reporting it as a stack trace read as a crash.
15
+ const summary = await run(options).catch((error) => stop(error instanceof Error ? error.message : USAGE));
16
+ for (const line of summary.notes) {
17
+ console.log(`worth a look: ${line}`);
18
+ }
19
+ for (const line of summary.unresolved) {
20
+ console.error(`needs a decision: ${line}`);
21
+ }
22
+ console.log(`${summary.changed.length} file(s) ${options.dryRun ? 'would change' : 'changed'}`);
23
+ if (summary.unresolved.length) {
24
+ console.error(`${summary.unresolved.length} property(ies) left untouched; see above`);
25
+ process.exit(1);
26
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { RunOptions } from './index.js';
2
+ export type ParsedArgs = {
3
+ readonly options: RunOptions;
4
+ /** Why the arguments were rejected. Nothing runs while this is non-empty. */
5
+ readonly errors: readonly string[];
6
+ };
7
+ /**
8
+ * Reads the command line, rejecting anything it does not recognise.
9
+ *
10
+ * Unknown arguments are an error rather than being ignored: a misspelled `--dry-run` would otherwise
11
+ * rewrite the project for real, which is the one mistake this tool must not let someone make quietly.
12
+ * Only the `--flag=value` form is accepted, so a stray `--project foo` cannot silently fall back to the
13
+ * default config either.
14
+ */
15
+ export declare function parseArgs(argv: readonly string[]): ParsedArgs;
package/dist/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ const VALUE_FLAGS = new Set(['project', 'include']);
2
+ /**
3
+ * Reads the command line, rejecting anything it does not recognise.
4
+ *
5
+ * Unknown arguments are an error rather than being ignored: a misspelled `--dry-run` would otherwise
6
+ * rewrite the project for real, which is the one mistake this tool must not let someone make quietly.
7
+ * Only the `--flag=value` form is accepted, so a stray `--project foo` cannot silently fall back to the
8
+ * default config either.
9
+ */
10
+ export function parseArgs(argv) {
11
+ const errors = [];
12
+ const values = new Map();
13
+ let dryRun = false;
14
+ for (const arg of argv) {
15
+ if (arg === '--dry-run') {
16
+ dryRun = true;
17
+ continue;
18
+ }
19
+ const separator = arg.indexOf('=');
20
+ const name = arg.slice(2, separator === -1 ? undefined : separator);
21
+ if (!arg.startsWith('--') || !VALUE_FLAGS.has(name)) {
22
+ errors.push(`unknown argument '${arg}'`);
23
+ continue;
24
+ }
25
+ const value = arg.slice(separator + 1);
26
+ if (separator === -1 || !value) {
27
+ errors.push(`'--${name}' needs a value, as '--${name}=<value>'`);
28
+ continue;
29
+ }
30
+ values.set(name, value);
31
+ }
32
+ return {
33
+ options: {
34
+ project: values.get('project') ?? 'tsconfig.json',
35
+ dryRun,
36
+ include: values.get('include')?.split(',').filter(Boolean),
37
+ },
38
+ errors,
39
+ };
40
+ }
@@ -0,0 +1,23 @@
1
+ import type ts from 'typescript';
2
+ /** One text replacement in a file. */
3
+ export type Edit = {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ readonly text: string;
7
+ };
8
+ /**
9
+ * Applies edits back to front, so an earlier edit cannot shift the offsets of a later one.
10
+ *
11
+ * Replacing text rather than reprinting the file is what keeps the rest of it byte-for-byte: a codemod
12
+ * that reformats everything it touches buries its own change in the diff.
13
+ */
14
+ export declare function applyEdits(text: string, edits: readonly Edit[]): string;
15
+ /**
16
+ * Removes one element of a comma-separated list - a parameter, a named import, a JSON property - taking
17
+ * the separator that would otherwise dangle. The last element gives up the comma before it; any other
18
+ * gives up the one after.
19
+ *
20
+ * The source file is passed explicitly because a tree from `ts.parseJsonText` carries no
21
+ * back-reference, so `getStart()` cannot find one on its own.
22
+ */
23
+ export declare function removeFromList(items: readonly ts.Node[], item: ts.Node, source: ts.SourceFile): Edit;
package/dist/edits.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Applies edits back to front, so an earlier edit cannot shift the offsets of a later one.
3
+ *
4
+ * Replacing text rather than reprinting the file is what keeps the rest of it byte-for-byte: a codemod
5
+ * that reformats everything it touches buries its own change in the diff.
6
+ */
7
+ export function applyEdits(text, edits) {
8
+ let result = text;
9
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
10
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
11
+ }
12
+ return result;
13
+ }
14
+ /**
15
+ * Removes one element of a comma-separated list - a parameter, a named import, a JSON property - taking
16
+ * the separator that would otherwise dangle. The last element gives up the comma before it; any other
17
+ * gives up the one after.
18
+ *
19
+ * The source file is passed explicitly because a tree from `ts.parseJsonText` carries no
20
+ * back-reference, so `getStart()` cannot find one on its own.
21
+ */
22
+ export function removeFromList(items, item, source) {
23
+ const at = items.indexOf(item);
24
+ const next = items[at + 1];
25
+ const previous = items[at - 1];
26
+ return {
27
+ start: previous && !next ? previous.getEnd() : item.getStart(source),
28
+ end: next ? next.getStart(source) : item.getEnd(),
29
+ text: '',
30
+ };
31
+ }
@@ -0,0 +1,19 @@
1
+ import ts from 'typescript';
2
+ /** A string type carrying more than `string`, e.g. `` type UUID = `${string}-${string}` ``. */
3
+ export declare function isBrandedString(type: ts.Type): boolean;
4
+ /**
5
+ * The `FieldType` expression to write for a property declared as `type`, or `undefined` when the shape is
6
+ * one a human has to decide on.
7
+ *
8
+ * Mirrors `TypeFor` in `uql-orm`, in the direction the codemod needs: from the property's TypeScript type
9
+ * to the `type` option that reflection used to supply. Only the cases reflection could actually produce
10
+ * are handled. A `Json<T>` or vector property already had to declare its `type` explicitly, because
11
+ * `design:type` reported the useless `Object`/`Array` for them, so any such property reaching here is
12
+ * genuinely ambiguous and is reported rather than guessed.
13
+ */
14
+ export declare function fieldTypeFor(type: ts.Type): string | undefined;
15
+ /**
16
+ * The entity name a relation property points at: `Company` for `Company`, `Company[]`, and
17
+ * `Relation<Company>` alike, since `Relation<T>` is an alias for `T` and erases before this runs.
18
+ */
19
+ export declare function relationTargetFor(type: ts.Type, checker: ts.TypeChecker): string | undefined;
@@ -0,0 +1,64 @@
1
+ import ts from 'typescript';
2
+ /** A string type carrying more than `string`, e.g. `` type UUID = `${string}-${string}` ``. */
3
+ export function isBrandedString(type) {
4
+ const branded = ts.TypeFlags.TemplateLiteral | ts.TypeFlags.StringMapping;
5
+ return type.isUnion() ? type.types.some((t) => !!(t.getFlags() & branded)) : !!(type.getFlags() & branded);
6
+ }
7
+ /**
8
+ * Reads a union by requiring its arms to agree, which is what an `'a' | 'b'` enum or a `T | null` looks
9
+ * like. `undefined` and `null` arms are dropped first: they carry no column type of their own.
10
+ */
11
+ function foldUnion(type, read) {
12
+ const arms = type.types.filter((arm) => !(arm.getFlags() & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)));
13
+ const readings = new Set(arms.map(read));
14
+ return readings.size === 1 ? [...readings][0] : undefined;
15
+ }
16
+ /**
17
+ * The `FieldType` expression to write for a property declared as `type`, or `undefined` when the shape is
18
+ * one a human has to decide on.
19
+ *
20
+ * Mirrors `TypeFor` in `uql-orm`, in the direction the codemod needs: from the property's TypeScript type
21
+ * to the `type` option that reflection used to supply. Only the cases reflection could actually produce
22
+ * are handled. A `Json<T>` or vector property already had to declare its `type` explicitly, because
23
+ * `design:type` reported the useless `Object`/`Array` for them, so any such property reaching here is
24
+ * genuinely ambiguous and is reported rather than guessed.
25
+ */
26
+ export function fieldTypeFor(type) {
27
+ const flags = type.getFlags();
28
+ if (type.isUnion()) {
29
+ return foldUnion(type, fieldTypeFor);
30
+ }
31
+ // `TemplateLiteral` and `StringMapping` cover the branded-string types projects use for ids, e.g.
32
+ // `type UUID = `${string}-${string}-${string}-${string}-${string}``, which are still string columns.
33
+ const stringLike = ts.TypeFlags.String | ts.TypeFlags.StringLiteral | ts.TypeFlags.TemplateLiteral | ts.TypeFlags.StringMapping;
34
+ if (flags & stringLike)
35
+ return 'String';
36
+ if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral))
37
+ return 'Number';
38
+ if (flags & (ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral))
39
+ return 'BigInt';
40
+ if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral))
41
+ return 'Boolean';
42
+ // Matched by declared symbol rather than by the printed type: both of these became generic in the
43
+ // modern lib, so `Uint8Array` prints as `Uint8Array<ArrayBufferLike>` and never compared equal.
44
+ const name = type.getSymbol()?.getName();
45
+ if (name === 'Date')
46
+ return 'Date';
47
+ if (name === 'Buffer' || name === 'Uint8Array')
48
+ return "'blob'";
49
+ return undefined;
50
+ }
51
+ /**
52
+ * The entity name a relation property points at: `Company` for `Company`, `Company[]`, and
53
+ * `Relation<Company>` alike, since `Relation<T>` is an alias for `T` and erases before this runs.
54
+ */
55
+ export function relationTargetFor(type, checker) {
56
+ if (type.isUnion()) {
57
+ return foldUnion(type, (arm) => relationTargetFor(arm, checker));
58
+ }
59
+ const elementType = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
60
+ const target = elementType ?? type;
61
+ const symbol = target.getSymbol();
62
+ const name = symbol?.getName();
63
+ return name && name !== '__type' && /^[A-Z][A-Za-z0-9_]*$/.test(name) ? name : undefined;
64
+ }
@@ -0,0 +1,22 @@
1
+ export * from './transform.js';
2
+ export * from './tsconfig.js';
3
+ export type RunOptions = {
4
+ /** Path to the project's `tsconfig.json`; the codemod needs a real program to read property types. */
5
+ readonly project: string;
6
+ /** Report what would change without touching anything. */
7
+ readonly dryRun?: boolean;
8
+ /** Restrict the run to files whose path contains one of these fragments. */
9
+ readonly include?: readonly string[];
10
+ };
11
+ export type RunSummary = {
12
+ readonly changed: readonly string[];
13
+ readonly unresolved: readonly string[];
14
+ readonly notes: readonly string[];
15
+ };
16
+ /**
17
+ * Rewrites a project's entities for the standard decorator spec.
18
+ *
19
+ * @remarks Needs a type checker, not just a parser: the whole point is to write down the types that
20
+ * `design:type` used to report at runtime, and only the checker knows what `role?: Role` resolves to.
21
+ */
22
+ export declare function run({ project, dryRun, include }: RunOptions): Promise<RunSummary>;
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import ts from 'typescript';
4
+ import { transformFile } from './transform.js';
5
+ import { transformTsconfig } from './tsconfig.js';
6
+ export * from './transform.js';
7
+ export * from './tsconfig.js';
8
+ /**
9
+ * Rewrites a project's entities for the standard decorator spec.
10
+ *
11
+ * @remarks Needs a type checker, not just a parser: the whole point is to write down the types that
12
+ * `design:type` used to report at runtime, and only the checker knows what `role?: Role` resolves to.
13
+ */
14
+ export async function run({ project, dryRun, include }) {
15
+ // Absolute, because `parseJsonConfigFileContent` resolves every discovered file against the base path
16
+ // and a relative one yields file names the program cannot then open.
17
+ const configPath = resolve(project);
18
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
19
+ if (config.error) {
20
+ throw new TypeError(ts.flattenDiagnosticMessageText(config.error.messageText, ' '));
21
+ }
22
+ const host = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath));
23
+ const program = ts.createProgram(host.fileNames, {
24
+ ...host.options,
25
+ // The codemod only ever reads types, so the emit target is irrelevant. It is pinned because a
26
+ // project may name a target newer than this TypeScript understands, which would otherwise be
27
+ // reported as a config error and leave the options half-applied.
28
+ target: ts.ScriptTarget.ESNext,
29
+ noEmit: true,
30
+ });
31
+ const checker = program.getTypeChecker();
32
+ const results = [];
33
+ for (const source of program.getSourceFiles()) {
34
+ if (source.isDeclarationFile || /[/\\]node_modules[/\\]/.test(source.fileName))
35
+ continue;
36
+ if (include?.length && !include.some((fragment) => source.fileName.includes(fragment)))
37
+ continue;
38
+ const result = transformFile(source, checker);
39
+ if (result.changed || result.unresolved.length || result.notes.length)
40
+ results.push(result);
41
+ }
42
+ // The config is rewritten too: leaving `experimentalDecorators` on is what keeps a project compiling
43
+ // against the old spec, so a codemod that only touched the sources would leave it half-migrated.
44
+ const tsconfig = transformTsconfig(configPath, ts.sys.readFile(configPath) ?? '');
45
+ if (!dryRun) {
46
+ await Promise.all(results.filter((r) => r.changed).map((r) => writeFile(r.fileName, r.text, 'utf8')));
47
+ if (tsconfig.changed) {
48
+ await writeFile(configPath, tsconfig.text, 'utf8');
49
+ }
50
+ }
51
+ return {
52
+ changed: [...results.filter((r) => r.changed).map((r) => r.fileName), ...(tsconfig.changed ? [configPath] : [])],
53
+ unresolved: [...results.flatMap((r) => r.unresolved), ...tsconfig.unresolved],
54
+ notes: results.flatMap((r) => r.notes),
55
+ };
56
+ }
@@ -0,0 +1,12 @@
1
+ import ts from 'typescript';
2
+ export type FileResult = {
3
+ readonly fileName: string;
4
+ readonly text: string;
5
+ readonly changed: boolean;
6
+ /** Properties the codemod refused to guess at, each needing a human. */
7
+ readonly unresolved: readonly string[];
8
+ /** Rewrites that are correct but worth a second look. */
9
+ readonly notes: readonly string[];
10
+ };
11
+ /** Rewrites one source file for the standard decorator spec. */
12
+ export declare function transformFile(source: ts.SourceFile, checker: ts.TypeChecker): FileResult;
@@ -0,0 +1,321 @@
1
+ import ts from 'typescript';
2
+ import { applyEdits, removeFromList } from './edits.js';
3
+ import { fieldTypeFor, isBrandedString, relationTargetFor } from './fieldType.js';
4
+ const FIELD_DECORATORS = new Set(['Field', 'Id']);
5
+ const RELATION_DECORATORS = new Set(['OneToOne', 'ManyToOne', 'OneToMany', 'ManyToMany']);
6
+ /**
7
+ * Decorators that no longer exist, and that the codemod does not remove for you.
8
+ *
9
+ * A standard-spec decorator cannot preserve a generic method's signature, which is why these went. What
10
+ * to do instead is a judgement call - inline the logging, drop the serialisation - so the usage is
11
+ * reported and left in place. Removing only the import would swap a clear error for a puzzling one.
12
+ */
13
+ const REMOVED_DECORATORS = new Set(['Log', 'Serialized']);
14
+ function decoratorName(node) {
15
+ const call = ts.isCallExpression(node.expression) ? node.expression : undefined;
16
+ const target = call?.expression ?? node.expression;
17
+ return ts.isIdentifier(target) ? target.text : undefined;
18
+ }
19
+ function decoratorOptions(node) {
20
+ if (!ts.isCallExpression(node.expression)) {
21
+ // `@Field` rather than `@Field()`: there is no argument list to write into, and adding one would mean
22
+ // guessing that this is the decorator factory the codemod thinks it is.
23
+ return { kind: 'opaque', reason: 'it is used without being called' };
24
+ }
25
+ const [first] = node.expression.arguments;
26
+ if (!first) {
27
+ return { kind: 'empty', call: node.expression };
28
+ }
29
+ if (!ts.isObjectLiteralExpression(first)) {
30
+ return { kind: 'opaque', reason: `its options are passed as '${first.getText()}'` };
31
+ }
32
+ return first.properties.some(ts.isSpreadAssignment)
33
+ ? { kind: 'opaque', reason: 'its options object spreads another' }
34
+ : { kind: 'literal', node: first };
35
+ }
36
+ function hasProperty(options, name) {
37
+ return (options.kind === 'literal' &&
38
+ options.node.properties.some((prop) => prop.name && ts.isIdentifier(prop.name) && prop.name.text === name));
39
+ }
40
+ /** Inserts `option` into the decorator's options object, creating one when the call has no arguments. */
41
+ function insertOption(options, option) {
42
+ if (options.kind === 'empty') {
43
+ // `@Field()` -> `@Field({ ... })`: the argument list is empty, so this only ever inserts.
44
+ const { arguments: args } = options.call;
45
+ return { start: args.pos, end: args.end, text: `{ ${option} }` };
46
+ }
47
+ const first = options.node.properties[0];
48
+ return first
49
+ ? { start: first.getStart(), end: first.getStart(), text: `${option}, ` }
50
+ : { start: options.node.getStart(), end: options.node.getEnd(), text: `{ ${option} }` };
51
+ }
52
+ /**
53
+ * Writes one option the decorator can no longer infer at runtime, or records why it could not.
54
+ *
55
+ * Returns the property's type when an edit was written, so a caller can say something about what was
56
+ * just decided. Nothing is written for options the codemod cannot read: replacing them would drop
57
+ * whatever the author put there.
58
+ */
59
+ function addInferredOption(decorator, node, ctx, spec) {
60
+ const options = decoratorOptions(decorator);
61
+ if (spec.satisfiedBy.some((name) => hasProperty(options, name))) {
62
+ return undefined;
63
+ }
64
+ if (options.kind === 'opaque') {
65
+ ctx.unresolved.push(`${ctx.describe(node)}: cannot add '${spec.option}' because ${options.reason}`);
66
+ return undefined;
67
+ }
68
+ const type = ctx.checker.getTypeAtLocation(node);
69
+ const value = spec.infer(type);
70
+ if (!value) {
71
+ ctx.unresolved.push(`${ctx.describe(node)}: cannot infer '${spec.option}' for ${ctx.checker.typeToString(type)}`);
72
+ return undefined;
73
+ }
74
+ ctx.edits.push(insertOption(options, `${spec.option}: ${value}`));
75
+ return type;
76
+ }
77
+ /** Writes the `type` that `design:type` used to supply at runtime. */
78
+ function addFieldType(decorator, node, ctx) {
79
+ const type = addInferredOption(decorator, node, ctx, {
80
+ option: 'type',
81
+ // A `references` field deliberately has no `type`: the column resolves from the primary key it
82
+ // points at, which also carries that key's `columnType` and length.
83
+ satisfiedBy: ['type', 'references'],
84
+ infer: fieldTypeFor,
85
+ });
86
+ // A branded id type (`type UUID = `${string}-${string}-...``) erased to `String` under reflection, so
87
+ // `String` is what keeps the existing column. It is often not what was wanted, though, and switching
88
+ // to `'uuid'` changes the generated column, so say so rather than decide.
89
+ if (type && isBrandedString(type)) {
90
+ ctx.notes.push(`${ctx.describe(node)}: set 'type: ${fieldTypeFor(type)}' for ` +
91
+ `${ctx.checker.typeToString(type)}, matching the previous behaviour. If the column should be ` +
92
+ "'uuid', change it deliberately: that alters the schema.");
93
+ }
94
+ }
95
+ /** Writes the `entity` getter relations can no longer infer. */
96
+ function addRelationEntity(decorator, node, ctx) {
97
+ addInferredOption(decorator, node, ctx, {
98
+ option: 'entity',
99
+ satisfiedBy: ['entity'],
100
+ infer: (t) => {
101
+ const target = relationTargetFor(t, ctx.checker);
102
+ return target && `() => ${target}`;
103
+ },
104
+ });
105
+ }
106
+ /**
107
+ * Drops `declare` from a decorated field. A `declare` field emits nothing, so the standard spec has
108
+ * nothing to decorate and rejects it ("Decorators are not valid here"). Legacy decorators tolerated it
109
+ * because they only ever reached the class through `target.constructor`.
110
+ */
111
+ function dropDeclare(node, ctx) {
112
+ const modifier = node.modifiers?.find((m) => m.kind === ts.SyntaxKind.DeclareKeyword);
113
+ if (modifier) {
114
+ // Through the whitespace that followed it, rather than a fixed one character: two spaces or a line
115
+ // break left the rest behind.
116
+ const text = node.getSourceFile().text;
117
+ let end = modifier.getEnd();
118
+ while (/\s/.test(text[end] ?? '')) {
119
+ end += 1;
120
+ }
121
+ ctx.edits.push({ start: modifier.getStart(), end, text: '' });
122
+ }
123
+ }
124
+ /**
125
+ * Unwraps `Relation<Company>` to `Company`. The alias is `type Relation<T> = T`, and it existed only
126
+ * because reflection stored the property's type at class-definition time, which forced a real import and
127
+ * so created the circular-import problem it was working around.
128
+ */
129
+ function unwrapRelationAlias(node, ctx) {
130
+ const declared = node.type;
131
+ if (declared &&
132
+ ts.isTypeReferenceNode(declared) &&
133
+ ts.isIdentifier(declared.typeName) &&
134
+ declared.typeName.text === 'Relation' &&
135
+ declared.typeArguments?.length === 1) {
136
+ ctx.edits.push({ start: declared.getStart(), end: declared.getEnd(), text: declared.typeArguments[0].getText() });
137
+ ctx.relationAlias.unwrapped += 1;
138
+ }
139
+ }
140
+ /** Counts `Relation<T>` wherever it appears, including the places this codemod does not rewrite. */
141
+ function countRelationAlias(node, ctx) {
142
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Relation') {
143
+ ctx.relationAlias.seen += 1;
144
+ }
145
+ }
146
+ /**
147
+ * Rewrites an `@InjectQuerier()` parameter into a `currentQuerier()` call.
148
+ *
149
+ * The standard spec has no parameter decorators, so the querier travels through async-local storage and
150
+ * is read inside the body. Only the plain shape is rewritten - a named parameter in a method with a body.
151
+ * A default value or a destructured parameter is reported: the author was doing something the codemod
152
+ * would have to guess at.
153
+ */
154
+ function rewriteInjectQuerier(method, ctx) {
155
+ for (const parameter of method.parameters) {
156
+ if (!ts.getDecorators(parameter)?.some((d) => decoratorName(d) === 'InjectQuerier')) {
157
+ continue;
158
+ }
159
+ if (!method.body || !ts.isIdentifier(parameter.name) || parameter.initializer) {
160
+ ctx.unresolved.push(`${ctx.describe(parameter)}: cannot rewrite this '@InjectQuerier()' parameter`);
161
+ continue;
162
+ }
163
+ ctx.edits.push(removeFromList(method.parameters, parameter, method.getSourceFile()));
164
+ // The querier is read at the top of the body, keeping the name the parameter had.
165
+ const body = method.body;
166
+ const first = body.statements[0];
167
+ const indent = first ? ' '.repeat(first.getStart() - body.getSourceFile().getLineStarts()[ctx.lineOf(first)]) : '';
168
+ ctx.edits.push({
169
+ start: body.getStart() + 1,
170
+ end: body.getStart() + 1,
171
+ text: `\n${indent}const ${parameter.name.text} = currentQuerier();`,
172
+ });
173
+ ctx.imports.add('currentQuerier');
174
+ }
175
+ }
176
+ /** Removes a statement together with the rest of its line, so nothing is left blank behind it. */
177
+ function removeStatement(node) {
178
+ const source = node.getSourceFile();
179
+ const line = source.getLineAndCharacterOfPosition(node.getEnd()).line;
180
+ const nextLineStart = source.getLineStarts()[line + 1];
181
+ return { start: node.getStart(), end: nextLineStart ?? node.getEnd(), text: '' };
182
+ }
183
+ /**
184
+ * Brings the file's imports in line with what the rewrites left behind, in one pass:
185
+ *
186
+ * - `import 'reflect-metadata'` goes, since the polyfill only ever fed `design:type`.
187
+ * - `InjectQuerier` goes with it, and its import would otherwise not compile: the standard spec has no
188
+ * parameter decorators, so `uql-orm` no longer exports one.
189
+ * - whatever a rewrite needs (`currentQuerier`) is added, extending an existing `uql-orm` import rather
190
+ * than adding a second one.
191
+ *
192
+ * One function because all three edit the same statements, and doing them separately meant an import
193
+ * could be dropped and re-added, or a name added to a statement another edit had already removed.
194
+ */
195
+ function reconcileImports(source, ctx) {
196
+ // Dropping runs first and hands over the `uql-orm` import it left standing: adding a name to a
197
+ // statement the other pass had already removed is the bug that made these one step to begin with.
198
+ addNeededImports(source, ctx, dropDeadImports(source, ctx));
199
+ }
200
+ /** Names `uql-orm` no longer exports. `Relation` joins them only when every usage was rewritten. */
201
+ function deadImportNames(source, ctx) {
202
+ const { seen, unwrapped } = ctx.relationAlias;
203
+ if (seen > unwrapped) {
204
+ ctx.unresolved.push(`${source.fileName}: ${seen - unwrapped} 'Relation<T>' reference(s) are somewhere this codemod does ` +
205
+ 'not rewrite; unwrap them and drop the import by hand');
206
+ }
207
+ return new Set(seen > 0 && seen === unwrapped ? ['InjectQuerier', 'Relation'] : ['InjectQuerier']);
208
+ }
209
+ /**
210
+ * Removes `import 'reflect-metadata'` and any name that no longer exists, and returns the `uql-orm`
211
+ * named imports still standing - `undefined` when there are none to extend.
212
+ */
213
+ function dropDeadImports(source, ctx) {
214
+ const dead = deadImportNames(source, ctx);
215
+ let host;
216
+ for (const statement of source.statements) {
217
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
218
+ continue;
219
+ }
220
+ const module = statement.moduleSpecifier.text;
221
+ if (module === 'reflect-metadata' && !statement.importClause) {
222
+ ctx.edits.push(removeStatement(statement));
223
+ continue;
224
+ }
225
+ const named = statement.importClause?.namedBindings;
226
+ if (!named || !ts.isNamedImports(named)) {
227
+ continue;
228
+ }
229
+ const dropped = named.elements.filter((element) => dead.has(element.name.text));
230
+ if (dropped.length === named.elements.length && !statement.importClause?.name) {
231
+ ctx.edits.push(removeStatement(statement));
232
+ continue;
233
+ }
234
+ for (const element of dropped) {
235
+ ctx.edits.push(removeFromList(named.elements, element, source));
236
+ }
237
+ if (module === 'uql-orm') {
238
+ host = named;
239
+ }
240
+ }
241
+ return host;
242
+ }
243
+ /** Adds what the rewrites need, extending `host` when there is one rather than adding a second import. */
244
+ function addNeededImports(source, ctx, host) {
245
+ const missing = [...ctx.imports].filter((name) => !host?.elements.some((element) => element.name.text === name));
246
+ if (!missing.length) {
247
+ return;
248
+ }
249
+ if (host) {
250
+ const last = host.elements[host.elements.length - 1];
251
+ ctx.edits.push({ start: last.getEnd(), end: last.getEnd(), text: `, ${missing.join(', ')}` });
252
+ return;
253
+ }
254
+ const position = source.statements[0]?.getStart() ?? 0;
255
+ ctx.edits.push({ start: position, end: position, text: `import { ${missing.join(', ')} } from 'uql-orm';\n` });
256
+ }
257
+ /** Everything the standard spec needs written onto one decorated property. */
258
+ function rewriteProperty(node, ctx) {
259
+ const decorators = ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
260
+ for (const decorator of decorators) {
261
+ const name = decoratorName(decorator);
262
+ if (!name) {
263
+ continue;
264
+ }
265
+ if (FIELD_DECORATORS.has(name)) {
266
+ addFieldType(decorator, node, ctx);
267
+ }
268
+ if (RELATION_DECORATORS.has(name)) {
269
+ addRelationEntity(decorator, node, ctx);
270
+ }
271
+ }
272
+ if (decorators.length) {
273
+ dropDeclare(node, ctx);
274
+ }
275
+ unwrapRelationAlias(node, ctx);
276
+ }
277
+ /** Names a decorator that no longer exists, wherever it appears. */
278
+ function reportRemovedDecorators(node, ctx) {
279
+ if (!ts.canHaveDecorators(node)) {
280
+ return;
281
+ }
282
+ for (const decorator of ts.getDecorators(node) ?? []) {
283
+ const name = decoratorName(decorator);
284
+ if (name && REMOVED_DECORATORS.has(name)) {
285
+ ctx.unresolved.push(`${ctx.describe(decorator)}: '@${name}()' was removed; delete it and its import`);
286
+ }
287
+ }
288
+ }
289
+ /** Rewrites one source file for the standard decorator spec. */
290
+ export function transformFile(source, checker) {
291
+ const ctx = {
292
+ checker,
293
+ edits: [],
294
+ unresolved: [],
295
+ notes: [],
296
+ imports: new Set(),
297
+ relationAlias: { seen: 0, unwrapped: 0 },
298
+ describe: (node) => `${source.fileName}:${ctx.lineOf(node) + 1}`,
299
+ lineOf: (node) => source.getLineAndCharacterOfPosition(node.getStart()).line,
300
+ };
301
+ const visit = (node) => {
302
+ reportRemovedDecorators(node, ctx);
303
+ countRelationAlias(node, ctx);
304
+ if (ts.isPropertyDeclaration(node)) {
305
+ rewriteProperty(node, ctx);
306
+ }
307
+ else if (ts.isMethodDeclaration(node)) {
308
+ rewriteInjectQuerier(node, ctx);
309
+ }
310
+ ts.forEachChild(node, visit);
311
+ };
312
+ visit(source);
313
+ reconcileImports(source, ctx);
314
+ return {
315
+ fileName: source.fileName,
316
+ text: applyEdits(source.getFullText(), ctx.edits),
317
+ changed: ctx.edits.length > 0,
318
+ unresolved: ctx.unresolved,
319
+ notes: ctx.notes,
320
+ };
321
+ }
@@ -0,0 +1,14 @@
1
+ export type TsconfigResult = {
2
+ readonly text: string;
3
+ readonly changed: boolean;
4
+ /** What the codemod will not decide, each needing a human. */
5
+ readonly unresolved: readonly string[];
6
+ };
7
+ /**
8
+ * Rewrites a `tsconfig.json` for the standard decorator spec.
9
+ *
10
+ * Edited as text through `ts.parseJsonText`, not by reading and re-serialising: a tsconfig is JSON with
11
+ * comments and someone's formatting, and rewriting the whole file to change two lines would throw both
12
+ * away.
13
+ */
14
+ export declare function transformTsconfig(fileName: string, text: string): TsconfigResult;
@@ -0,0 +1,48 @@
1
+ import ts from 'typescript';
2
+ import { applyEdits, removeFromList } from './edits.js';
3
+ /** The flags the standard spec has no use for. Their presence is what keeps a project on the old one. */
4
+ const LEGACY_FLAGS = ['experimentalDecorators', 'emitDecoratorMetadata'];
5
+ /**
6
+ * Rewrites a `tsconfig.json` for the standard decorator spec.
7
+ *
8
+ * Edited as text through `ts.parseJsonText`, not by reading and re-serialising: a tsconfig is JSON with
9
+ * comments and someone's formatting, and rewriting the whole file to change two lines would throw both
10
+ * away.
11
+ */
12
+ export function transformTsconfig(fileName, text) {
13
+ const source = ts.parseJsonText(fileName, text);
14
+ const root = source.statements[0]?.expression;
15
+ if (!root || !ts.isObjectLiteralExpression(root)) {
16
+ return { text, changed: false, unresolved: [`${fileName}: not an object, so nothing was changed`] };
17
+ }
18
+ const edits = [];
19
+ const unresolved = [];
20
+ const compilerOptions = propertyValue(root, 'compilerOptions');
21
+ if (compilerOptions && ts.isObjectLiteralExpression(compilerOptions)) {
22
+ for (const flag of LEGACY_FLAGS) {
23
+ const property = propertyAssignment(compilerOptions, flag);
24
+ if (property) {
25
+ edits.push(removeFromList(compilerOptions.properties, property, source));
26
+ }
27
+ }
28
+ // `target` is reported, not rewritten: removing it silently falls back to the compiler default
29
+ // (`es5` for `tsc`), and choosing a replacement means guessing which era the project targets.
30
+ const target = propertyValue(compilerOptions, 'target');
31
+ if (target && ts.isStringLiteral(target) && target.text.toLowerCase() === 'esnext') {
32
+ unresolved.push(`${fileName}: 'target' is 'esnext', the one target that emits decorator syntax untransformed. ` +
33
+ 'Change it to any dated target, such as `es2024`.');
34
+ }
35
+ }
36
+ // A value the file inherits cannot be edited here, and silence would read as "there was none".
37
+ if (propertyValue(root, 'extends') && !edits.length) {
38
+ unresolved.push(`${fileName}: extends another config, so check the base for 'experimentalDecorators', ` +
39
+ "'emitDecoratorMetadata' and 'target'.");
40
+ }
41
+ return { text: applyEdits(text, edits), changed: edits.length > 0, unresolved };
42
+ }
43
+ function propertyAssignment(object, name) {
44
+ return object.properties.find((property) => ts.isPropertyAssignment(property) && ts.isStringLiteral(property.name) && property.name.text === name);
45
+ }
46
+ function propertyValue(object, name) {
47
+ return propertyAssignment(object, name)?.initializer;
48
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "uql-codemod",
3
+ "description": "Migrates uql-orm entities from legacy decorators to the TC39 standard spec",
4
+ "license": "MIT",
5
+ "version": "0.23.0",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "bin": {
11
+ "uql-codemod": "./dist/bin.js"
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": "./dist/index.js",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "build": "rimraf dist .tsconfig.tsbuildinfo && tsc -b tsconfig.build.json",
25
+ "clean": "rimraf dist .tsconfig.tsbuildinfo"
26
+ },
27
+ "dependencies": {
28
+ "typescript": "^6.0.3"
29
+ },
30
+ "author": "Roger Padilla",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/rogerpadilla/uql.git",
34
+ "directory": "packages/uql-codemod"
35
+ },
36
+ "keywords": [
37
+ "uql",
38
+ "codemod",
39
+ "decorators",
40
+ "migration"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "gitHead": "3bfca5950ac564b8503fb2140726501b01825b9d"
46
+ }