@rvoh/psychic 2.0.3 → 2.1.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.
@@ -1,7 +1,7 @@
1
1
  import { CliFileWriter, DreamBin, DreamCLI } from '@rvoh/dream/system';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
- import TypesBuilder from '../cli/helpers/TypesBuilder.js';
4
+ import ASTPsychicTypesBuilder from '../cli/helpers/ASTPsychicTypesBuilder.js';
5
5
  import generateController from '../generate/controller.js';
6
6
  import generateResource from '../generate/resource.js';
7
7
  import isObject from '../helpers/isObject.js';
@@ -54,11 +54,10 @@ export default class PsychicBin {
54
54
  await CliFileWriter.revert();
55
55
  }
56
56
  }
57
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
- static async syncTypes(customTypes = undefined) {
59
- DreamCLI.logger.logStartProgress(`syncing types/psychic.ts...`);
60
- await TypesBuilder.sync(customTypes);
61
- DreamCLI.logger.logEndProgress();
57
+ static async syncTypes() {
58
+ await DreamCLI.logger.logProgress(`syncing types/psychic.ts...`, async () => {
59
+ await new ASTPsychicTypesBuilder().build();
60
+ });
62
61
  }
63
62
  static async syncOpenapiTypescriptFiles() {
64
63
  DreamCLI.logger.logStartProgress(`syncing openapi types...`);
@@ -117,8 +116,5 @@ export default class PsychicBin {
117
116
  output = { ...output, ...res };
118
117
  }
119
118
  }
120
- if (Object.keys(output).length) {
121
- await PsychicBin.syncTypes(output);
122
- }
123
119
  }
124
120
  }
@@ -0,0 +1,175 @@
1
+ import { DreamApp } from '@rvoh/dream';
2
+ import * as path from 'node:path';
3
+ import ts from 'typescript';
4
+ const f = ts.factory;
5
+ /**
6
+ * @internal
7
+ *
8
+ * This is a base class, which is inherited by the ASTSchemaBuilder,
9
+ * the ASTKyselyCodegenEnhancer, and the ASTGlobalSchemaBuilder,
10
+ * each of which is responsible for building up the output of the various
11
+ * type files consumed by dream internally.
12
+ *
13
+ * This base class is just a container for common methods used by all
14
+ * classes.
15
+ */
16
+ export default class ASTBuilder {
17
+ /**
18
+ * @internal
19
+ *
20
+ * builds a new line, useful for injecting new lines into AST statements
21
+ */
22
+ newLine() {
23
+ return f.createIdentifier('\n');
24
+ }
25
+ /**
26
+ * @internal
27
+ *
28
+ * given an interface declaration, it will extrace the relevant property statement
29
+ * by the given property name.
30
+ */
31
+ getPropertyFromInterface(interfaceNode, propertyName) {
32
+ for (const member of interfaceNode.members) {
33
+ if (ts.isPropertySignature(member)) {
34
+ if (ts.isIdentifier(member.name) && member.name.text === propertyName) {
35
+ return member;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * @internal
43
+ *
44
+ * returns an array of string type literals which were extracted from
45
+ * either a type or type union, depending on what is provided
46
+ * for the typeAlias. this allows you to safely and easily collect
47
+ * an array of types given an alias
48
+ */
49
+ extractStringLiteralTypeNodesFromTypeOrUnion(typeAlias) {
50
+ const literals = [];
51
+ if (ts.isUnionTypeNode(typeAlias.type)) {
52
+ typeAlias.type.types.forEach(typeNode => {
53
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
54
+ literals.push(typeNode);
55
+ }
56
+ });
57
+ }
58
+ else if (ts.isLiteralTypeNode(typeAlias.type) && ts.isStringLiteral(typeAlias.type.literal)) {
59
+ literals.push(typeAlias.type);
60
+ }
61
+ return literals;
62
+ }
63
+ /**
64
+ * @internal
65
+ *
66
+ * returns an array of type literals which were extracted from
67
+ * either a type or type union, depending on what is provided
68
+ * for the typeAlias. this allows you to safely and easily collect
69
+ * an array of types given an alias
70
+ */
71
+ extractTypeNodesFromTypeOrUnion(typeAlias) {
72
+ const literals = [];
73
+ if (typeAlias.type && ts.isUnionTypeNode(typeAlias.type)) {
74
+ typeAlias.type.types.forEach(typeNode => {
75
+ literals.push(typeNode);
76
+ });
77
+ }
78
+ else if (typeAlias.type) {
79
+ literals.push(typeAlias.type);
80
+ }
81
+ return literals;
82
+ }
83
+ /**
84
+ * @internal
85
+ *
86
+ * returns the provided node iff
87
+ * a.) the node is an exported type alias
88
+ * b.) the exported name matches the provided name (or else there was no name provided)
89
+ *
90
+ * otherwise, returns null
91
+ */
92
+ exportedTypeAliasOrNull(node, exportName) {
93
+ if (ts.isTypeAliasDeclaration(node) &&
94
+ node?.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
95
+ (!exportName ? true : node.name.text === exportName))
96
+ return node;
97
+ return null;
98
+ }
99
+ /**
100
+ * @internal
101
+ *
102
+ * returns the provided node iff
103
+ * a.) the node is an exported interface
104
+ * b.) the exported name matches the provided name (or else there was no name provided)
105
+ *
106
+ * otherwise, returns null
107
+ */
108
+ exportedInterfaceOrNull(node, exportName) {
109
+ if (ts.isInterfaceDeclaration(node) &&
110
+ node?.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
111
+ (!exportName ? true : node.name.text === exportName))
112
+ return node;
113
+ return null;
114
+ }
115
+ /**
116
+ * @internal
117
+ *
118
+ * returns the path to the dream.globals.ts file
119
+ */
120
+ psychicTypesPath() {
121
+ const dreamApp = DreamApp.getOrFail();
122
+ return path.join(dreamApp.projectRoot, dreamApp.paths.types, 'psychic.ts');
123
+ }
124
+ /**
125
+ * @internal
126
+ *
127
+ * safely runs prettier against the provided output. If prettier
128
+ * is not installed, then the original output is returned
129
+ */
130
+ async prettier(output) {
131
+ try {
132
+ // dynamically, safely bring in prettier.
133
+ // ini the event that it fails, we will return the
134
+ // original output, unformatted, since prettier
135
+ // is technically not a real dependency of dream,
136
+ // though psychic and dream apps are provisioned
137
+ // with prettier by default, so this should usually work
138
+ const prettier = (await import('prettier')).default;
139
+ const results = await prettier.format(output, {
140
+ parser: 'typescript',
141
+ semi: false,
142
+ singleQuote: true,
143
+ tabWidth: 2,
144
+ lineWidth: 80,
145
+ });
146
+ return typeof results === 'string' ? results : output;
147
+ }
148
+ catch {
149
+ // intentional noop, we don't want to raise if prettier
150
+ // fails, since it is possible for the end user to not
151
+ // want to use prettier, and it is not a required peer
152
+ // dependency of dream
153
+ return output;
154
+ }
155
+ }
156
+ /**
157
+ * @internal
158
+ *
159
+ * given a type node, it will send back the first found generic
160
+ * provided to that type.
161
+ */
162
+ getFirstGenericType(node) {
163
+ if (ts.isTypeReferenceNode(node)) {
164
+ if (node.typeArguments && node.typeArguments.length > 0) {
165
+ return node.typeArguments[0];
166
+ }
167
+ }
168
+ else if (ts.isCallExpression(node)) {
169
+ if (node.typeArguments && node.typeArguments.length > 0) {
170
+ return node.typeArguments[0];
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ }
@@ -0,0 +1,59 @@
1
+ import { CliFileWriter, DreamCLI } from '@rvoh/dream/system';
2
+ import ts from 'typescript';
3
+ import PsychicApp from '../../psychic-app/index.js';
4
+ import ASTBuilder from './ASTBuilder.js';
5
+ const f = ts.factory;
6
+ /**
7
+ * Responsible for building dream globals, which can be found at
8
+ * types/dream.globals.ts.
9
+ *
10
+ * This class leverages internal AST building mechanisms built into
11
+ * typescript to manually build up object literals and interfaces
12
+ * for our app to consume.
13
+ */
14
+ export default class ASTPsychicTypesBuilder extends ASTBuilder {
15
+ async build() {
16
+ const logger = DreamCLI.logger;
17
+ const sourceFile = ts.createSourceFile('', '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
18
+ await logger.logProgress('[psychic] building psychic types', async () => {
19
+ const output = await this.prettier(this.printStatements(this.buildPsychicTypes(), sourceFile));
20
+ await CliFileWriter.write(this.psychicTypesPath(), output);
21
+ });
22
+ }
23
+ /**
24
+ * @internal
25
+ *
26
+ * builds up the `export const psychicTypes = ...` statement within the types/psychic.ts
27
+ * file. It does this by leveraging low-level AST utils built into typescript
28
+ * to manually build up an object literal, cast it as a const, and write it to
29
+ * an exported variable.
30
+ */
31
+ buildPsychicTypes() {
32
+ const psychicApp = PsychicApp.getOrFail();
33
+ const psychicTypesObjectLiteral = f.createObjectLiteralExpression([
34
+ f.createPropertyAssignment(f.createIdentifier('openapiNames'), f.createArrayLiteralExpression(Object.keys(psychicApp.openapi).map(key => f.createStringLiteral(key)))),
35
+ ], true);
36
+ // add "as const" to the end of the schema object we
37
+ // have built before returning it
38
+ const constAssertion = f.createAsExpression(psychicTypesObjectLiteral, f.createKeywordTypeNode(ts.SyntaxKind.ConstKeyword));
39
+ const psychicTypesObjectLiteralConst = f.createVariableStatement(undefined, f.createVariableDeclarationList([
40
+ f.createVariableDeclaration(f.createIdentifier('psychicTypes'), undefined, undefined, constAssertion),
41
+ ], ts.NodeFlags.Const));
42
+ const defaultExportIdentifier = f.createIdentifier('psychicTypes');
43
+ const exportDefaultStatement = f.createExportDefault(defaultExportIdentifier);
44
+ return [psychicTypesObjectLiteralConst, this.newLine(), exportDefaultStatement];
45
+ }
46
+ /**
47
+ * @internal
48
+ *
49
+ * writes the compiled statements to string.
50
+ *
51
+ */
52
+ printStatements(statements, sourceFile) {
53
+ const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, omitTrailingSemicolon: true });
54
+ const result = printer.printList(ts.ListFormat.SourceFileStatements, f.createNodeArray(statements), sourceFile);
55
+ // TODO: add autogenerate disclaimer
56
+ return `\
57
+ ${result}`;
58
+ }
59
+ }
@@ -66,7 +66,7 @@ export default class PsychicCLI {
66
66
  program
67
67
  .command('generate:resource')
68
68
  .alias('g:resource')
69
- .description('create a Dream model, migration, controller, serializer, and spec placeholders')
69
+ .description('Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.')
70
70
  .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes')
71
71
  .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
72
72
  - index
@@ -91,7 +91,7 @@ export default class PsychicCLI {
91
91
  program
92
92
  .command('generate:controller')
93
93
  .alias('g:controller')
94
- .description('create a controller')
94
+ .description('Generates a controller and the inheritance chain leading to that controller, and a spec skeleton for the controller.')
95
95
  .argument('<controllerName>', 'the name of the controller to create, including namespaces, e.g. Posts or Api/V1/Posts')
96
96
  .argument('[actions...]', 'the names of controller actions to create')
97
97
  .action(async (controllerName, actions) => {
@@ -101,7 +101,7 @@ export default class PsychicCLI {
101
101
  });
102
102
  program
103
103
  .command('setup:sync:enums')
104
- .description('generates an initializer in your app for syncing enums to a particular path.')
104
+ .description('Generates an initializer in your app for syncing enums to a particular path.')
105
105
  .argument('<outfile>', 'the path from your backend directory to the location which you want the enums copied. Should end with .ts, i.e. "../client/src/api/enums.ts"')
106
106
  .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-enums.ts`')
107
107
  .action(async (outfile, { initializerName, }) => {
@@ -114,7 +114,7 @@ export default class PsychicCLI {
114
114
  });
115
115
  program
116
116
  .command('setup:sync:openapi-redux')
117
- .description('generates openapi redux bindings to connect one of your openapi files to one of your clients')
117
+ .description('Generates openapi redux bindings to connect one of your openapi files to one of your clients.')
118
118
  .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
119
119
  .option('--api-file <apiFile>', 'the path to the boilerplate api file that will be used to scaffold your backend endpoints together with, i.e. ../client/app/api.ts')
120
120
  .option('--api-import <apiImport>', 'the camelCased name of the export from your api module, i.e. emptyBackendApi')
@@ -136,7 +136,7 @@ export default class PsychicCLI {
136
136
  });
137
137
  program
138
138
  .command('setup:sync:openapi-typescript')
139
- .description('generates an initializer in your app for converting one of your openapi files to typescript')
139
+ .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
140
140
  .argument('<openapiFilepath>', 'the path from your backend directory to the openapi file you wish to scan, i.e. "./src/openapi/openapi.json"')
141
141
  .argument('<outfile>', 'the path from your backend directory to the location which you want the openapi types written to. Must end with .d.ts, i.e. "./src/conf/openapi/openapi.types.d.ts"')
142
142
  .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-openapi-typescript.ts`')
@@ -150,7 +150,7 @@ export default class PsychicCLI {
150
150
  });
151
151
  program
152
152
  .command('routes')
153
- .description('examines your current models, building a type-map of the associations so that the ORM can understand your relational setup. This is commited to your repo, and synced to the dream repo for consumption within the underlying library.')
153
+ .description('Prints a list of routes defined by the application, including path arguments and the controller/action reached by the route.')
154
154
  .action(async () => {
155
155
  await initializePsychicApp();
156
156
  PsychicBin.printRoutes();
@@ -158,7 +158,7 @@ export default class PsychicCLI {
158
158
  });
159
159
  program
160
160
  .command('sync')
161
- .description('sync introspects your database, updating your schema to reflect, and then syncs the new schema with the installed dream node module, allowing it provide your schema to the underlying kysely integration')
161
+ .description("Generates types from the current state of the database. Generates OpenAPI specs from @OpenAPI decorated controller actions. Additional sync actions may be customized with `on('cli:sync', async () => {})` in conf/app.ts or in an initializer in `conf/initializers/`.")
162
162
  .option('--ignore-errors')
163
163
  .option('--schema-only')
164
164
  .action(async (options) => {
@@ -184,13 +184,13 @@ export default class PsychicCLI {
184
184
  });
185
185
  program
186
186
  .command('sync:routes')
187
- .description('reads the routes generated by your app and generates a cache file, which is then used to give autocomplete support to the route helper, amoongst other things.')
187
+ .description('Reads the routes generated by your app and generates a cache file, which is then used to give autocomplete support to the route helper and other things.')
188
188
  .action(async () => {
189
189
  await PsychicBin.syncRoutes();
190
190
  });
191
191
  program
192
192
  .command('sync:openapi')
193
- .description('syncs openapi.json file to current state of all psychic controllers within the app')
193
+ .description('Syncs openapi.json file to current state of all psychic controllers within the app')
194
194
  .action(async () => {
195
195
  await initializePsychicApp();
196
196
  await PsychicBin.syncOpenapiJson();
@@ -1,7 +1,7 @@
1
1
  import { CliFileWriter, DreamBin, DreamCLI } from '@rvoh/dream/system';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
- import TypesBuilder from '../cli/helpers/TypesBuilder.js';
4
+ import ASTPsychicTypesBuilder from '../cli/helpers/ASTPsychicTypesBuilder.js';
5
5
  import generateController from '../generate/controller.js';
6
6
  import generateResource from '../generate/resource.js';
7
7
  import isObject from '../helpers/isObject.js';
@@ -54,11 +54,10 @@ export default class PsychicBin {
54
54
  await CliFileWriter.revert();
55
55
  }
56
56
  }
57
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
- static async syncTypes(customTypes = undefined) {
59
- DreamCLI.logger.logStartProgress(`syncing types/psychic.ts...`);
60
- await TypesBuilder.sync(customTypes);
61
- DreamCLI.logger.logEndProgress();
57
+ static async syncTypes() {
58
+ await DreamCLI.logger.logProgress(`syncing types/psychic.ts...`, async () => {
59
+ await new ASTPsychicTypesBuilder().build();
60
+ });
62
61
  }
63
62
  static async syncOpenapiTypescriptFiles() {
64
63
  DreamCLI.logger.logStartProgress(`syncing openapi types...`);
@@ -117,8 +116,5 @@ export default class PsychicBin {
117
116
  output = { ...output, ...res };
118
117
  }
119
118
  }
120
- if (Object.keys(output).length) {
121
- await PsychicBin.syncTypes(output);
122
- }
123
119
  }
124
120
  }
@@ -0,0 +1,175 @@
1
+ import { DreamApp } from '@rvoh/dream';
2
+ import * as path from 'node:path';
3
+ import ts from 'typescript';
4
+ const f = ts.factory;
5
+ /**
6
+ * @internal
7
+ *
8
+ * This is a base class, which is inherited by the ASTSchemaBuilder,
9
+ * the ASTKyselyCodegenEnhancer, and the ASTGlobalSchemaBuilder,
10
+ * each of which is responsible for building up the output of the various
11
+ * type files consumed by dream internally.
12
+ *
13
+ * This base class is just a container for common methods used by all
14
+ * classes.
15
+ */
16
+ export default class ASTBuilder {
17
+ /**
18
+ * @internal
19
+ *
20
+ * builds a new line, useful for injecting new lines into AST statements
21
+ */
22
+ newLine() {
23
+ return f.createIdentifier('\n');
24
+ }
25
+ /**
26
+ * @internal
27
+ *
28
+ * given an interface declaration, it will extrace the relevant property statement
29
+ * by the given property name.
30
+ */
31
+ getPropertyFromInterface(interfaceNode, propertyName) {
32
+ for (const member of interfaceNode.members) {
33
+ if (ts.isPropertySignature(member)) {
34
+ if (ts.isIdentifier(member.name) && member.name.text === propertyName) {
35
+ return member;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * @internal
43
+ *
44
+ * returns an array of string type literals which were extracted from
45
+ * either a type or type union, depending on what is provided
46
+ * for the typeAlias. this allows you to safely and easily collect
47
+ * an array of types given an alias
48
+ */
49
+ extractStringLiteralTypeNodesFromTypeOrUnion(typeAlias) {
50
+ const literals = [];
51
+ if (ts.isUnionTypeNode(typeAlias.type)) {
52
+ typeAlias.type.types.forEach(typeNode => {
53
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
54
+ literals.push(typeNode);
55
+ }
56
+ });
57
+ }
58
+ else if (ts.isLiteralTypeNode(typeAlias.type) && ts.isStringLiteral(typeAlias.type.literal)) {
59
+ literals.push(typeAlias.type);
60
+ }
61
+ return literals;
62
+ }
63
+ /**
64
+ * @internal
65
+ *
66
+ * returns an array of type literals which were extracted from
67
+ * either a type or type union, depending on what is provided
68
+ * for the typeAlias. this allows you to safely and easily collect
69
+ * an array of types given an alias
70
+ */
71
+ extractTypeNodesFromTypeOrUnion(typeAlias) {
72
+ const literals = [];
73
+ if (typeAlias.type && ts.isUnionTypeNode(typeAlias.type)) {
74
+ typeAlias.type.types.forEach(typeNode => {
75
+ literals.push(typeNode);
76
+ });
77
+ }
78
+ else if (typeAlias.type) {
79
+ literals.push(typeAlias.type);
80
+ }
81
+ return literals;
82
+ }
83
+ /**
84
+ * @internal
85
+ *
86
+ * returns the provided node iff
87
+ * a.) the node is an exported type alias
88
+ * b.) the exported name matches the provided name (or else there was no name provided)
89
+ *
90
+ * otherwise, returns null
91
+ */
92
+ exportedTypeAliasOrNull(node, exportName) {
93
+ if (ts.isTypeAliasDeclaration(node) &&
94
+ node?.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
95
+ (!exportName ? true : node.name.text === exportName))
96
+ return node;
97
+ return null;
98
+ }
99
+ /**
100
+ * @internal
101
+ *
102
+ * returns the provided node iff
103
+ * a.) the node is an exported interface
104
+ * b.) the exported name matches the provided name (or else there was no name provided)
105
+ *
106
+ * otherwise, returns null
107
+ */
108
+ exportedInterfaceOrNull(node, exportName) {
109
+ if (ts.isInterfaceDeclaration(node) &&
110
+ node?.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
111
+ (!exportName ? true : node.name.text === exportName))
112
+ return node;
113
+ return null;
114
+ }
115
+ /**
116
+ * @internal
117
+ *
118
+ * returns the path to the dream.globals.ts file
119
+ */
120
+ psychicTypesPath() {
121
+ const dreamApp = DreamApp.getOrFail();
122
+ return path.join(dreamApp.projectRoot, dreamApp.paths.types, 'psychic.ts');
123
+ }
124
+ /**
125
+ * @internal
126
+ *
127
+ * safely runs prettier against the provided output. If prettier
128
+ * is not installed, then the original output is returned
129
+ */
130
+ async prettier(output) {
131
+ try {
132
+ // dynamically, safely bring in prettier.
133
+ // ini the event that it fails, we will return the
134
+ // original output, unformatted, since prettier
135
+ // is technically not a real dependency of dream,
136
+ // though psychic and dream apps are provisioned
137
+ // with prettier by default, so this should usually work
138
+ const prettier = (await import('prettier')).default;
139
+ const results = await prettier.format(output, {
140
+ parser: 'typescript',
141
+ semi: false,
142
+ singleQuote: true,
143
+ tabWidth: 2,
144
+ lineWidth: 80,
145
+ });
146
+ return typeof results === 'string' ? results : output;
147
+ }
148
+ catch {
149
+ // intentional noop, we don't want to raise if prettier
150
+ // fails, since it is possible for the end user to not
151
+ // want to use prettier, and it is not a required peer
152
+ // dependency of dream
153
+ return output;
154
+ }
155
+ }
156
+ /**
157
+ * @internal
158
+ *
159
+ * given a type node, it will send back the first found generic
160
+ * provided to that type.
161
+ */
162
+ getFirstGenericType(node) {
163
+ if (ts.isTypeReferenceNode(node)) {
164
+ if (node.typeArguments && node.typeArguments.length > 0) {
165
+ return node.typeArguments[0];
166
+ }
167
+ }
168
+ else if (ts.isCallExpression(node)) {
169
+ if (node.typeArguments && node.typeArguments.length > 0) {
170
+ return node.typeArguments[0];
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ }
@@ -0,0 +1,59 @@
1
+ import { CliFileWriter, DreamCLI } from '@rvoh/dream/system';
2
+ import ts from 'typescript';
3
+ import PsychicApp from '../../psychic-app/index.js';
4
+ import ASTBuilder from './ASTBuilder.js';
5
+ const f = ts.factory;
6
+ /**
7
+ * Responsible for building dream globals, which can be found at
8
+ * types/dream.globals.ts.
9
+ *
10
+ * This class leverages internal AST building mechanisms built into
11
+ * typescript to manually build up object literals and interfaces
12
+ * for our app to consume.
13
+ */
14
+ export default class ASTPsychicTypesBuilder extends ASTBuilder {
15
+ async build() {
16
+ const logger = DreamCLI.logger;
17
+ const sourceFile = ts.createSourceFile('', '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
18
+ await logger.logProgress('[psychic] building psychic types', async () => {
19
+ const output = await this.prettier(this.printStatements(this.buildPsychicTypes(), sourceFile));
20
+ await CliFileWriter.write(this.psychicTypesPath(), output);
21
+ });
22
+ }
23
+ /**
24
+ * @internal
25
+ *
26
+ * builds up the `export const psychicTypes = ...` statement within the types/psychic.ts
27
+ * file. It does this by leveraging low-level AST utils built into typescript
28
+ * to manually build up an object literal, cast it as a const, and write it to
29
+ * an exported variable.
30
+ */
31
+ buildPsychicTypes() {
32
+ const psychicApp = PsychicApp.getOrFail();
33
+ const psychicTypesObjectLiteral = f.createObjectLiteralExpression([
34
+ f.createPropertyAssignment(f.createIdentifier('openapiNames'), f.createArrayLiteralExpression(Object.keys(psychicApp.openapi).map(key => f.createStringLiteral(key)))),
35
+ ], true);
36
+ // add "as const" to the end of the schema object we
37
+ // have built before returning it
38
+ const constAssertion = f.createAsExpression(psychicTypesObjectLiteral, f.createKeywordTypeNode(ts.SyntaxKind.ConstKeyword));
39
+ const psychicTypesObjectLiteralConst = f.createVariableStatement(undefined, f.createVariableDeclarationList([
40
+ f.createVariableDeclaration(f.createIdentifier('psychicTypes'), undefined, undefined, constAssertion),
41
+ ], ts.NodeFlags.Const));
42
+ const defaultExportIdentifier = f.createIdentifier('psychicTypes');
43
+ const exportDefaultStatement = f.createExportDefault(defaultExportIdentifier);
44
+ return [psychicTypesObjectLiteralConst, this.newLine(), exportDefaultStatement];
45
+ }
46
+ /**
47
+ * @internal
48
+ *
49
+ * writes the compiled statements to string.
50
+ *
51
+ */
52
+ printStatements(statements, sourceFile) {
53
+ const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, omitTrailingSemicolon: true });
54
+ const result = printer.printList(ts.ListFormat.SourceFileStatements, f.createNodeArray(statements), sourceFile);
55
+ // TODO: add autogenerate disclaimer
56
+ return `\
57
+ ${result}`;
58
+ }
59
+ }
@@ -66,7 +66,7 @@ export default class PsychicCLI {
66
66
  program
67
67
  .command('generate:resource')
68
68
  .alias('g:resource')
69
- .description('create a Dream model, migration, controller, serializer, and spec placeholders')
69
+ .description('Generates a Dream model with corresponding spec factory, serializer, migration, and controller with the inheritance chain leading to that controller, with fleshed out specs for each resourceful action in the controller.')
70
70
  .option('--singular', 'generates a "resource" route instead of "resources", along with the necessary controller and spec changes')
71
71
  .option('--only <onlyActions>', `comma separated list of resourceful endpionts (e.g. "--only=create,show"); any of:
72
72
  - index
@@ -91,7 +91,7 @@ export default class PsychicCLI {
91
91
  program
92
92
  .command('generate:controller')
93
93
  .alias('g:controller')
94
- .description('create a controller')
94
+ .description('Generates a controller and the inheritance chain leading to that controller, and a spec skeleton for the controller.')
95
95
  .argument('<controllerName>', 'the name of the controller to create, including namespaces, e.g. Posts or Api/V1/Posts')
96
96
  .argument('[actions...]', 'the names of controller actions to create')
97
97
  .action(async (controllerName, actions) => {
@@ -101,7 +101,7 @@ export default class PsychicCLI {
101
101
  });
102
102
  program
103
103
  .command('setup:sync:enums')
104
- .description('generates an initializer in your app for syncing enums to a particular path.')
104
+ .description('Generates an initializer in your app for syncing enums to a particular path.')
105
105
  .argument('<outfile>', 'the path from your backend directory to the location which you want the enums copied. Should end with .ts, i.e. "../client/src/api/enums.ts"')
106
106
  .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-enums.ts`')
107
107
  .action(async (outfile, { initializerName, }) => {
@@ -114,7 +114,7 @@ export default class PsychicCLI {
114
114
  });
115
115
  program
116
116
  .command('setup:sync:openapi-redux')
117
- .description('generates openapi redux bindings to connect one of your openapi files to one of your clients')
117
+ .description('Generates openapi redux bindings to connect one of your openapi files to one of your clients.')
118
118
  .option('--schema-file <schemaFile>', 'the path from your api root to the openapi file you wish to use to generate your schema, i.e. ./src/openapi/openapi.json')
119
119
  .option('--api-file <apiFile>', 'the path to the boilerplate api file that will be used to scaffold your backend endpoints together with, i.e. ../client/app/api.ts')
120
120
  .option('--api-import <apiImport>', 'the camelCased name of the export from your api module, i.e. emptyBackendApi')
@@ -136,7 +136,7 @@ export default class PsychicCLI {
136
136
  });
137
137
  program
138
138
  .command('setup:sync:openapi-typescript')
139
- .description('generates an initializer in your app for converting one of your openapi files to typescript')
139
+ .description('Generates an initializer in your app for converting one of your openapi files to typescript.')
140
140
  .argument('<openapiFilepath>', 'the path from your backend directory to the openapi file you wish to scan, i.e. "./src/openapi/openapi.json"')
141
141
  .argument('<outfile>', 'the path from your backend directory to the location which you want the openapi types written to. Must end with .d.ts, i.e. "./src/conf/openapi/openapi.types.d.ts"')
142
142
  .option('--initializer-filename <initializerFilename>', 'the name you want the file to be in your initializers folder. defaults to `sync-openapi-typescript.ts`')
@@ -150,7 +150,7 @@ export default class PsychicCLI {
150
150
  });
151
151
  program
152
152
  .command('routes')
153
- .description('examines your current models, building a type-map of the associations so that the ORM can understand your relational setup. This is commited to your repo, and synced to the dream repo for consumption within the underlying library.')
153
+ .description('Prints a list of routes defined by the application, including path arguments and the controller/action reached by the route.')
154
154
  .action(async () => {
155
155
  await initializePsychicApp();
156
156
  PsychicBin.printRoutes();
@@ -158,7 +158,7 @@ export default class PsychicCLI {
158
158
  });
159
159
  program
160
160
  .command('sync')
161
- .description('sync introspects your database, updating your schema to reflect, and then syncs the new schema with the installed dream node module, allowing it provide your schema to the underlying kysely integration')
161
+ .description("Generates types from the current state of the database. Generates OpenAPI specs from @OpenAPI decorated controller actions. Additional sync actions may be customized with `on('cli:sync', async () => {})` in conf/app.ts or in an initializer in `conf/initializers/`.")
162
162
  .option('--ignore-errors')
163
163
  .option('--schema-only')
164
164
  .action(async (options) => {
@@ -184,13 +184,13 @@ export default class PsychicCLI {
184
184
  });
185
185
  program
186
186
  .command('sync:routes')
187
- .description('reads the routes generated by your app and generates a cache file, which is then used to give autocomplete support to the route helper, amoongst other things.')
187
+ .description('Reads the routes generated by your app and generates a cache file, which is then used to give autocomplete support to the route helper and other things.')
188
188
  .action(async () => {
189
189
  await PsychicBin.syncRoutes();
190
190
  });
191
191
  program
192
192
  .command('sync:openapi')
193
- .description('syncs openapi.json file to current state of all psychic controllers within the app')
193
+ .description('Syncs openapi.json file to current state of all psychic controllers within the app')
194
194
  .action(async () => {
195
195
  await initializePsychicApp();
196
196
  await PsychicBin.syncOpenapiJson();
@@ -13,7 +13,7 @@ export default class PsychicBin {
13
13
  schemaOnly?: boolean;
14
14
  }): Promise<void>;
15
15
  static postSync(): Promise<void>;
16
- static syncTypes(customTypes?: any): Promise<void>;
16
+ static syncTypes(): Promise<void>;
17
17
  static syncOpenapiTypescriptFiles(): Promise<void>;
18
18
  static syncOpenapiJson(): Promise<void>;
19
19
  static syncRoutes(): Promise<void>;
@@ -0,0 +1,89 @@
1
+ import ts from 'typescript';
2
+ /**
3
+ * @internal
4
+ *
5
+ * This is a base class, which is inherited by the ASTSchemaBuilder,
6
+ * the ASTKyselyCodegenEnhancer, and the ASTGlobalSchemaBuilder,
7
+ * each of which is responsible for building up the output of the various
8
+ * type files consumed by dream internally.
9
+ *
10
+ * This base class is just a container for common methods used by all
11
+ * classes.
12
+ */
13
+ export default class ASTBuilder {
14
+ /**
15
+ * @internal
16
+ *
17
+ * builds a new line, useful for injecting new lines into AST statements
18
+ */
19
+ protected newLine(): ts.Identifier;
20
+ /**
21
+ * @internal
22
+ *
23
+ * given an interface declaration, it will extrace the relevant property statement
24
+ * by the given property name.
25
+ */
26
+ protected getPropertyFromInterface(interfaceNode: ts.InterfaceDeclaration, propertyName: string): ts.PropertySignature | null;
27
+ /**
28
+ * @internal
29
+ *
30
+ * returns an array of string type literals which were extracted from
31
+ * either a type or type union, depending on what is provided
32
+ * for the typeAlias. this allows you to safely and easily collect
33
+ * an array of types given an alias
34
+ */
35
+ protected extractStringLiteralTypeNodesFromTypeOrUnion(typeAlias: ts.TypeAliasDeclaration): (ts.LiteralTypeNode & {
36
+ literal: {
37
+ text: string;
38
+ };
39
+ })[];
40
+ /**
41
+ * @internal
42
+ *
43
+ * returns an array of type literals which were extracted from
44
+ * either a type or type union, depending on what is provided
45
+ * for the typeAlias. this allows you to safely and easily collect
46
+ * an array of types given an alias
47
+ */
48
+ protected extractTypeNodesFromTypeOrUnion(typeAlias: ts.TypeAliasDeclaration | ts.PropertySignature): ts.TypeNode[];
49
+ /**
50
+ * @internal
51
+ *
52
+ * returns the provided node iff
53
+ * a.) the node is an exported type alias
54
+ * b.) the exported name matches the provided name (or else there was no name provided)
55
+ *
56
+ * otherwise, returns null
57
+ */
58
+ protected exportedTypeAliasOrNull(node: ts.Node, exportName?: string): ts.TypeAliasDeclaration | null;
59
+ /**
60
+ * @internal
61
+ *
62
+ * returns the provided node iff
63
+ * a.) the node is an exported interface
64
+ * b.) the exported name matches the provided name (or else there was no name provided)
65
+ *
66
+ * otherwise, returns null
67
+ */
68
+ protected exportedInterfaceOrNull(node: ts.Node, exportName?: string): ts.InterfaceDeclaration | null;
69
+ /**
70
+ * @internal
71
+ *
72
+ * returns the path to the dream.globals.ts file
73
+ */
74
+ protected psychicTypesPath(): string;
75
+ /**
76
+ * @internal
77
+ *
78
+ * safely runs prettier against the provided output. If prettier
79
+ * is not installed, then the original output is returned
80
+ */
81
+ protected prettier(output: string): Promise<string>;
82
+ /**
83
+ * @internal
84
+ *
85
+ * given a type node, it will send back the first found generic
86
+ * provided to that type.
87
+ */
88
+ protected getFirstGenericType(node: ts.Node): ts.TypeNode | null;
89
+ }
@@ -0,0 +1,28 @@
1
+ import ASTBuilder from './ASTBuilder.js';
2
+ /**
3
+ * Responsible for building dream globals, which can be found at
4
+ * types/dream.globals.ts.
5
+ *
6
+ * This class leverages internal AST building mechanisms built into
7
+ * typescript to manually build up object literals and interfaces
8
+ * for our app to consume.
9
+ */
10
+ export default class ASTPsychicTypesBuilder extends ASTBuilder {
11
+ build(): Promise<void>;
12
+ /**
13
+ * @internal
14
+ *
15
+ * builds up the `export const psychicTypes = ...` statement within the types/psychic.ts
16
+ * file. It does this by leveraging low-level AST utils built into typescript
17
+ * to manually build up an object literal, cast it as a const, and write it to
18
+ * an exported variable.
19
+ */
20
+ private buildPsychicTypes;
21
+ /**
22
+ * @internal
23
+ *
24
+ * writes the compiled statements to string.
25
+ *
26
+ */
27
+ private printStatements;
28
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "2.0.3",
5
+ "version": "2.1.0",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",
@@ -81,7 +81,7 @@
81
81
  "@rvoh/dream": "^2.0.3",
82
82
  "@types/express": "^5.0.1",
83
83
  "commander": "^12.1.0",
84
- "express": "^5.1.0",
84
+ "express": "^5.2.1",
85
85
  "openapi-typescript": "^7.8.0"
86
86
  },
87
87
  "devDependencies": {
@@ -90,7 +90,7 @@
90
90
  "@rvoh/dream": "^2.0.3",
91
91
  "@rvoh/dream-spec-helpers": "^2.0.0",
92
92
  "@rvoh/psychic-spec-helpers": "^2.0.0",
93
- "@types/express": "^5.0.1",
93
+ "@types/express": "^5.0.6",
94
94
  "@types/express-session": "^1.18.2",
95
95
  "@types/node": "^22.17.1",
96
96
  "@types/passport": "^0",
@@ -99,7 +99,7 @@
99
99
  "@types/supertest": "^6.0.3",
100
100
  "@typescript/analyze-trace": "^0.10.1",
101
101
  "eslint": "^9.19.0",
102
- "express": "^5.1.0",
102
+ "express": "^5.2.1",
103
103
  "express-session": "^1.18.2",
104
104
  "jsdom": "^26.1.0",
105
105
  "kysely": "^0.28.5",
@@ -1,23 +0,0 @@
1
- import { DreamApp } from '@rvoh/dream';
2
- import { CliFileWriter } from '@rvoh/dream/system';
3
- import * as path from 'node:path';
4
- import PsychicApp from '../../psychic-app/index.js';
5
- export default class TypesBuilder {
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- static async sync(customTypes = undefined) {
8
- const dreamApp = DreamApp.getOrFail();
9
- const schemaPath = path.join(dreamApp.projectRoot, dreamApp.paths.types, 'psychic.ts');
10
- await CliFileWriter.write(schemaPath, this.build(customTypes));
11
- }
12
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
- static build(customTypes = undefined) {
14
- const psychicApp = PsychicApp.getOrFail();
15
- const output = {
16
- openapiNames: Object.keys(psychicApp.openapi),
17
- ...(customTypes || {}),
18
- };
19
- return `const psychicTypes = ${JSON.stringify(output, null, 2)} as const
20
-
21
- export default psychicTypes`;
22
- }
23
- }
@@ -1,23 +0,0 @@
1
- import { DreamApp } from '@rvoh/dream';
2
- import { CliFileWriter } from '@rvoh/dream/system';
3
- import * as path from 'node:path';
4
- import PsychicApp from '../../psychic-app/index.js';
5
- export default class TypesBuilder {
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- static async sync(customTypes = undefined) {
8
- const dreamApp = DreamApp.getOrFail();
9
- const schemaPath = path.join(dreamApp.projectRoot, dreamApp.paths.types, 'psychic.ts');
10
- await CliFileWriter.write(schemaPath, this.build(customTypes));
11
- }
12
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
- static build(customTypes = undefined) {
14
- const psychicApp = PsychicApp.getOrFail();
15
- const output = {
16
- openapiNames: Object.keys(psychicApp.openapi),
17
- ...(customTypes || {}),
18
- };
19
- return `const psychicTypes = ${JSON.stringify(output, null, 2)} as const
20
-
21
- export default psychicTypes`;
22
- }
23
- }
@@ -1,7 +0,0 @@
1
- export default class TypesBuilder {
2
- static sync(customTypes?: any): Promise<void>;
3
- static build(customTypes?: any): string;
4
- }
5
- export interface PsychicTypeSync {
6
- openapiNames: string[];
7
- }