@rvoh/psychic 2.1.0 → 2.2.1-alpha.1

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.
Files changed (25) hide show
  1. package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +341 -0
  2. package/dist/cjs/src/bin/index.js +7 -0
  3. package/dist/cjs/src/cli/index.js +26 -1
  4. package/dist/cjs/src/controller/index.js +1 -1
  5. package/dist/cjs/src/devtools/helpers/launchDevServer.js +1 -1
  6. package/dist/cjs/src/error/psychic-app/init-missing-package-manager.js +1 -1
  7. package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +85 -11
  8. package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
  9. package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
  10. package/dist/cjs/src/openapi-renderer/app.js +1 -1
  11. package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +341 -0
  12. package/dist/esm/src/bin/index.js +7 -0
  13. package/dist/esm/src/cli/index.js +26 -1
  14. package/dist/esm/src/controller/index.js +1 -1
  15. package/dist/esm/src/devtools/helpers/launchDevServer.js +1 -1
  16. package/dist/esm/src/error/psychic-app/init-missing-package-manager.js +1 -1
  17. package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +85 -11
  18. package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
  19. package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
  20. package/dist/esm/src/openapi-renderer/app.js +1 -1
  21. package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +138 -0
  22. package/dist/types/src/bin/index.d.ts +2 -0
  23. package/dist/types/src/controller/index.d.ts +1 -1
  24. package/dist/types/src/psychic-app/index.d.ts +28 -0
  25. package/package.json +16 -13
@@ -0,0 +1,341 @@
1
+ import { DreamCLI } from '@rvoh/dream/system';
2
+ import * as cp from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import colorize from '../../cli/helpers/colorize.js';
6
+ /**
7
+ * Class-based OpenAPI specification diff tool
8
+ *
9
+ * Compares current OpenAPI specs against the head branch using oasdiff
10
+ *
11
+ * Example usages:
12
+ *
13
+ * Instance-based usage
14
+ * const diffTool = new OpenApiSpecDiff()
15
+ * diffTool.compare(openapiConfigs)
16
+ *
17
+ * Factory method usage
18
+ * const diffTool = OpenApiSpecDiff.create()
19
+ * diffTool.compare(openapiConfigs)
20
+ *
21
+ * Static method usage (backward compatibility)
22
+ * OpenApiSpecDiff.compare(openapiConfigs)
23
+ */
24
+ export class OpenApiSpecDiff {
25
+ /**
26
+ * The configuration for the oasdiff command
27
+ */
28
+ oasdiffConfig;
29
+ /**
30
+ * Compares a list of OpenAPI specifications between the current branch and the head branch.
31
+ *
32
+ * Uses `oasdiff` under the hood to detect breaking and non-breaking changes for each file,
33
+ * helping you review and validate API updates with confidence before merging.
34
+ *
35
+ * This tool only runs for configurations where `checkDiffs` is enabled.
36
+ *
37
+ * @param openapiConfigs - An array of tuples containing the OpenAPI file name and its configuration.
38
+ * @example
39
+ * ```ts
40
+ * const openapiConfigs: [string, PsychicOpenapiConfig][] = [
41
+ * ['openapi', { outputFilepath: 'openapi.json' }],
42
+ * ]
43
+ * OpenApiSpecDiff.compare(openapiConfigs)
44
+ * ```
45
+ */
46
+ compare(openapiConfigs) {
47
+ const results = [];
48
+ this.oasdiffConfig = this.getOasDiffConfig();
49
+ const comparing = colorize(`🔍 Comparing current OpenAPI Specs against ${this.oasdiffConfig.headBranch}...`, { color: 'cyanBright' });
50
+ DreamCLI.logger.logStartProgress(comparing, { logPrefixColor: 'cyanBright' });
51
+ DreamCLI.logger.logContinueProgress(`\n`, { logPrefixColor: 'cyanBright' });
52
+ for (const [openapiName, config] of openapiConfigs) {
53
+ const result = this.compareConfig(openapiName, config);
54
+ results.push(result);
55
+ }
56
+ this.processResults(results);
57
+ }
58
+ /**
59
+ * Checks if oasdiff is installed locally
60
+ */
61
+ hasOasDiffInstalled() {
62
+ try {
63
+ cp.execSync('oasdiff --version', { stdio: 'ignore' });
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Fetch the head branch of a remote using git remote show origin
72
+ * default to main if not set
73
+ */
74
+ getHeadBranch() {
75
+ let head = '';
76
+ const output = cp.execSync('git remote show origin', { encoding: 'utf-8' });
77
+ const lines = output.split('\n');
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+ if (trimmed.startsWith('HEAD branch:')) {
81
+ head = trimmed.replace('HEAD branch:', '').trim() || 'main';
82
+ }
83
+ }
84
+ return head;
85
+ }
86
+ /**
87
+ * Validates that oasdiff is installed and builds the oasdiff config
88
+ */
89
+ getOasDiffConfig() {
90
+ const headBranch = this.getHeadBranch();
91
+ if (this.hasOasDiffInstalled()) {
92
+ DreamCLI.logger.logContinueProgress('🎉 oasdiff package found\n');
93
+ return {
94
+ command: 'oasdiff',
95
+ baseArgs: [],
96
+ headBranch,
97
+ };
98
+ }
99
+ throw new Error(`⚠️ oasdiff not found.
100
+
101
+ Install it via the instructions here:
102
+ https://github.com/tufin/oasdiff
103
+ `);
104
+ }
105
+ /**
106
+ * Runs oasdiff command and returns the output
107
+ */
108
+ runOasDiffCommand(subcommand, mainPath, currentPath, flags) {
109
+ if (!this.oasdiffConfig) {
110
+ throw new Error('OasDiff config not initialized');
111
+ }
112
+ const args = [...this.oasdiffConfig.baseArgs, subcommand, mainPath, currentPath];
113
+ if (flags && flags.length > 0) {
114
+ args.push(...flags.map(flag => `--${flag}`));
115
+ }
116
+ try {
117
+ const output = cp.execFileSync(this.oasdiffConfig.command, args, {
118
+ shell: true,
119
+ encoding: 'utf8',
120
+ cwd: process.cwd(),
121
+ stdio: 'pipe',
122
+ });
123
+ return output.trim();
124
+ }
125
+ catch (error) {
126
+ const errorOutput = error instanceof Error ? error.message : String(error);
127
+ return errorOutput;
128
+ }
129
+ }
130
+ /**
131
+ * Compares two OpenAPI files using oasdiff
132
+ */
133
+ compareSpecs(mainFilePath, currentFilePath) {
134
+ if (!this.oasdiffConfig) {
135
+ throw new Error('OasDiff config not initialized');
136
+ }
137
+ const breakingChanges = this.runOasDiffCommand('breaking', mainFilePath, currentFilePath);
138
+ const changelogChanges = this.runOasDiffCommand('changelog', mainFilePath, currentFilePath);
139
+ const breaking = breakingChanges && !breakingChanges.includes('Command failed')
140
+ ? breakingChanges.split('\n').filter(line => line.trim())
141
+ : [];
142
+ const changelog = changelogChanges && !changelogChanges.includes('Command failed')
143
+ ? changelogChanges.split('\n').filter(line => line.trim())
144
+ : [];
145
+ const failedToCompare = breakingChanges.includes('Command failed')
146
+ ? breakingChanges
147
+ : changelogChanges.includes('Command failed')
148
+ ? changelogChanges
149
+ : '';
150
+ return {
151
+ breaking,
152
+ changelog,
153
+ error: failedToCompare,
154
+ };
155
+ }
156
+ /**
157
+ * Creates a temporary file path for the head branch content
158
+ */
159
+ createTempFilePath(filePath) {
160
+ const tempFileName = `temp_main_${path.basename(filePath)}`;
161
+ return path.join(path.dirname(filePath), tempFileName);
162
+ }
163
+ /**
164
+ * Retrieves head branch content for a file
165
+ */
166
+ getHeadBranchContent(filePath) {
167
+ if (!this.oasdiffConfig) {
168
+ throw new Error('OasDiff config not initialized');
169
+ }
170
+ const branchRef = process.env.CI === '1' ? `origin/${this.oasdiffConfig.headBranch}` : this.oasdiffConfig.headBranch;
171
+ return cp.execSync(`git show ${branchRef}:${filePath}`, {
172
+ encoding: 'utf8',
173
+ cwd: process.cwd(),
174
+ });
175
+ }
176
+ /**
177
+ * Compares a single OpenAPI file against head branch
178
+ */
179
+ compareConfig(openapiName, config) {
180
+ const result = {
181
+ file: openapiName,
182
+ hasChanges: false,
183
+ breaking: [],
184
+ changelog: [],
185
+ };
186
+ const currentFilePath = config.outputFilepath;
187
+ if (!fs.existsSync(currentFilePath)) {
188
+ result.error = `File ${config.outputFilepath} does not exist in current branch`;
189
+ return result;
190
+ }
191
+ const tempMainFilePath = this.createTempFilePath(config.outputFilepath);
192
+ const mainContent = this.getHeadBranchContent(config.outputFilepath);
193
+ fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
194
+ fs.writeFileSync(tempMainFilePath, mainContent);
195
+ try {
196
+ const { breaking, changelog, error } = this.compareSpecs(tempMainFilePath, currentFilePath);
197
+ result.breaking = breaking;
198
+ result.changelog = changelog;
199
+ result.hasChanges = breaking.length > 0 || changelog.length > 0;
200
+ result.error = error ?? '';
201
+ }
202
+ catch (error) {
203
+ result.error = `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(error)}`;
204
+ }
205
+ finally {
206
+ if (fs.existsSync(tempMainFilePath)) {
207
+ fs.unlinkSync(tempMainFilePath);
208
+ }
209
+ }
210
+ return result;
211
+ }
212
+ /**
213
+ * Process and display the comparison results
214
+ */
215
+ processResults(results) {
216
+ let hasAnyChanges = false;
217
+ let hasBreakingChanges = false;
218
+ for (const result of results) {
219
+ if (result.error) {
220
+ this.logError(result);
221
+ }
222
+ else if (result.hasChanges) {
223
+ this.logChanges(result);
224
+ hasAnyChanges = true;
225
+ if (result.breaking.length > 0) {
226
+ this.logBreakingChanges(result);
227
+ hasBreakingChanges = true;
228
+ }
229
+ if (result.changelog.length > 0) {
230
+ this.logChangelog(result);
231
+ }
232
+ }
233
+ else {
234
+ this.logNoChanges(result);
235
+ }
236
+ }
237
+ this.logSummary(hasAnyChanges, hasBreakingChanges);
238
+ }
239
+ /**
240
+ * Log error for a comparison result
241
+ */
242
+ logError(result) {
243
+ const file = colorize(`❌ ${result.file}`, { color: 'whiteBright' });
244
+ const error = colorize(`${result.error}`, { color: 'redBright' });
245
+ DreamCLI.logger.logContinueProgress(`${file}: ${error}`, { logPrefixColor: 'redBright' });
246
+ }
247
+ /**
248
+ * Log changes for a comparison result
249
+ */
250
+ logChanges(result) {
251
+ const file = colorize(`${result.file}`, { color: 'whiteBright' });
252
+ const changes = colorize('HAS CHANGES', { color: 'yellowBright' });
253
+ DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'yellowBright' });
254
+ }
255
+ /**
256
+ * Log breaking changes for a comparison result
257
+ */
258
+ logBreakingChanges(result) {
259
+ DreamCLI.logger.logContinueProgress(` ${colorize(`🚨 BREAKING CHANGES:`, { color: 'redBright' })}`, {
260
+ logPrefixColor: 'redBright',
261
+ });
262
+ result.breaking.forEach(change => {
263
+ DreamCLI.logger.logContinueProgress(` ${colorize(`• ${change}`, { color: 'redBright' })}`, {
264
+ logPrefixColor: 'redBright',
265
+ });
266
+ });
267
+ }
268
+ /**
269
+ * Log changelog for a comparison result
270
+ */
271
+ logChangelog(result) {
272
+ DreamCLI.logger.logContinueProgress(` ${colorize(`📋 CHANGELOG:`, { color: 'blueBright' })}`, {
273
+ logPrefixColor: 'blueBright',
274
+ });
275
+ const changelogLines = result.changelog;
276
+ changelogLines.forEach(line => {
277
+ DreamCLI.logger.logContinueProgress(` ${colorize(line, { color: 'whiteBright' })}`, {
278
+ logPrefixBgColor: 'bgWhite',
279
+ logPrefixColor: 'white',
280
+ });
281
+ });
282
+ }
283
+ /**
284
+ * Log no changes for a comparison result
285
+ */
286
+ logNoChanges(result) {
287
+ const file = colorize(`${result.file}`, { color: 'whiteBright' });
288
+ const changes = colorize('No changes', { color: 'greenBright' });
289
+ DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'greenBright' });
290
+ }
291
+ /**
292
+ * Log final summary and handle exit conditions
293
+ */
294
+ logSummary(hasAnyChanges, hasBreakingChanges) {
295
+ DreamCLI.logger.logContinueProgress(`\n${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
296
+ logPrefixColor: 'gray',
297
+ });
298
+ if (hasBreakingChanges) {
299
+ DreamCLI.logger.logContinueProgress(`${colorize(`🚨 CRITICAL:`, { color: 'redBright' })} ${colorize(`Breaking changes detected in current branch compared to ${this.oasdiffConfig?.headBranch}! Review before merging.`, { color: 'whiteBright' })}`, { logPrefixColor: 'redBright' });
300
+ DreamCLI.logger.logContinueProgress(`${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
301
+ logPrefixColor: 'gray',
302
+ });
303
+ DreamCLI.logger.logContinueProgress('\n'.repeat(5), {
304
+ logPrefixColor: 'gray',
305
+ });
306
+ throw new BreakingChangesDetectedInOpenApiSpecError(this.oasdiffConfig);
307
+ }
308
+ else if (hasAnyChanges) {
309
+ const summary = colorize(`📊 Summary: Some OpenAPI files have non-breaking changes in current branch compared to ${this.oasdiffConfig?.headBranch}`, { color: 'yellow' });
310
+ DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'yellow' });
311
+ }
312
+ else {
313
+ const summary = colorize(`📊 Summary: All OpenAPI files in current branch are identical to ${this.oasdiffConfig?.headBranch} branch`, { color: 'green' });
314
+ DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'green' });
315
+ }
316
+ }
317
+ /**
318
+ * Static factory method for convenience
319
+ */
320
+ static create() {
321
+ return new OpenApiSpecDiff();
322
+ }
323
+ /**
324
+ * Static method to maintain compatibility with functional approach
325
+ */
326
+ static compare(openapiConfigs) {
327
+ const instance = OpenApiSpecDiff.create();
328
+ instance.compare(openapiConfigs);
329
+ }
330
+ }
331
+ export class BreakingChangesDetectedInOpenApiSpecError extends Error {
332
+ oasdiffConfig;
333
+ constructor(oasdiffConfig) {
334
+ super();
335
+ this.oasdiffConfig = oasdiffConfig;
336
+ this.name = 'BreakingChangesDetectedInOpenApiSpecError';
337
+ }
338
+ get message() {
339
+ return `Breaking changes detected in current branch compared to ${this.oasdiffConfig.headBranch}! Review before merging.`;
340
+ }
341
+ }
@@ -9,7 +9,9 @@ import OpenapiAppRenderer from '../openapi-renderer/app.js';
9
9
  import PsychicApp from '../psychic-app/index.js';
10
10
  import enumsFileStr from './helpers/enumsFileStr.js';
11
11
  import generateRouteTypes from './helpers/generateRouteTypes.js';
12
+ import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
12
13
  import printRoutes from './helpers/printRoutes.js';
14
+ export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
13
15
  export default class PsychicBin {
14
16
  static async generateController(controllerName, actions) {
15
17
  await generateController({
@@ -59,6 +61,11 @@ export default class PsychicBin {
59
61
  await new ASTPsychicTypesBuilder().build();
60
62
  });
61
63
  }
64
+ static openapiDiff() {
65
+ const psychicApp = PsychicApp.getOrFail();
66
+ const openapiConfigsWithCheckDiffs = Object.entries(psychicApp.openapi).filter(([, config]) => config.checkDiffs);
67
+ OpenApiSpecDiff.compare(openapiConfigsWithCheckDiffs);
68
+ }
62
69
  static async syncOpenapiTypescriptFiles() {
63
70
  DreamCLI.logger.logStartProgress(`syncing openapi types...`);
64
71
  // https://rvohealth.atlassian.net/browse/PDTC-8359
@@ -1,5 +1,5 @@
1
1
  import { DreamCLI } from '@rvoh/dream/system';
2
- import PsychicBin from '../bin/index.js';
2
+ import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError } from '../bin/index.js';
3
3
  import generateController from '../generate/controller.js';
4
4
  import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
5
5
  import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
@@ -14,6 +14,10 @@ ${INDENT}all properties default to not nullable; null can be allowed by appendin
14
14
  ${INDENT} subtitle:string:optional
15
15
  ${INDENT}
16
16
  ${INDENT}supported types:
17
+ ${INDENT} - uuid:
18
+ ${INDENT} - uuid[]:
19
+ ${INDENT} a column optimized for storing UUIDs
20
+ ${INDENT}
17
21
  ${INDENT} - citext:
18
22
  ${INDENT} - citext[]:
19
23
  ${INDENT} case insensitive text (indexes and queries are automatically case insensitive)
@@ -196,6 +200,27 @@ export default class PsychicCLI {
196
200
  await PsychicBin.syncOpenapiJson();
197
201
  process.exit();
198
202
  });
203
+ program
204
+ .command('diff:openapi')
205
+ .description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
206
+ .option('-f', '--fail-on-breaking', 'fail on spec changes that are breaking')
207
+ .action(async (options) => {
208
+ await initializePsychicApp();
209
+ try {
210
+ PsychicBin.openapiDiff();
211
+ }
212
+ catch (error) {
213
+ if (error instanceof BreakingChangesDetectedInOpenApiSpecError) {
214
+ if (options.failOnBreaking) {
215
+ console.error(error.message);
216
+ process.exit(1);
217
+ }
218
+ }
219
+ else {
220
+ throw error;
221
+ }
222
+ }
223
+ });
199
224
  }
200
225
  /**
201
226
  * @internal
@@ -131,7 +131,7 @@ export default class PsychicController {
131
131
  /**
132
132
  * @internal
133
133
  *
134
- * Used for displaying routes when running `yarn psy routes`
134
+ * Used for displaying routes when running `pnpm psy routes`
135
135
  * cli command
136
136
  */
137
137
  static get disaplayName() {
@@ -6,7 +6,7 @@ import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
6
6
  import PsychicApp from '../../psychic-app/index.js';
7
7
  const devServerProcesses = {};
8
8
  const debugEnabled = debuglog('psychic').enabled;
9
- export async function launchDevServer(key, { port = 3000, cmd = 'yarn client', timeout = 5000 } = {}) {
9
+ export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000 } = {}) {
10
10
  if (devServerProcesses[key])
11
11
  return;
12
12
  if (debugEnabled)
@@ -10,7 +10,7 @@ within conf/app.ts, you must have a call to "#set('packageManager', '<YOUR_CHOSE
10
10
 
11
11
  // conf/app.ts
12
12
  export default async (psy: PsychicApp) => {
13
- psy.set('packageManager', 'yarn')
13
+ psy.set('packageManager', 'pnpm')
14
14
  }
15
15
  `;
16
16
  }
@@ -7,7 +7,7 @@ export default function generateResourceControllerSpecContent(options) {
7
7
  const modelConfig = createModelConfiguration(options);
8
8
  const actionConfig = createActionConfiguration(options);
9
9
  const attributeData = processAttributes(options.columnsWithTypes, modelConfig, options.fullyQualifiedModelName);
10
- const imports = generateImportStatements(modelConfig, attributeData.dreamImports, options.owningModel);
10
+ const imports = generateImportStatements(modelConfig, attributeData.dreamImports, options.owningModel, attributeData.uuidAttributeIncluded);
11
11
  return generateSpecTemplate({
12
12
  ...options,
13
13
  path,
@@ -62,6 +62,8 @@ function processAttributes(columnsWithTypes, modelConfig, fullyQualifiedModelNam
62
62
  originalValueVariableAssignments: [],
63
63
  dateAttributeIncluded: false,
64
64
  datetimeAttributeIncluded: false,
65
+ uuidAttributeIncluded: false,
66
+ uuidArrayAttributes: [],
65
67
  dreamImports: [],
66
68
  };
67
69
  for (const attribute of columnsWithTypes) {
@@ -87,8 +89,9 @@ function parseAttribute(attribute) {
87
89
  const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
88
90
  if (!rawAttributeName || !rawAttributeType)
89
91
  return null;
92
+ const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
90
93
  // Handle belongs_to relationships
91
- if (rawAttributeType === 'belongs_to') {
94
+ if (sanitizedAttrType === 'belongsto') {
92
95
  // For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
93
96
  const attributeName = camelize(rawAttributeName.split('/').pop());
94
97
  return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
@@ -101,12 +104,14 @@ function parseAttribute(attribute) {
101
104
  return null;
102
105
  const arrayBracketRegexp = /\[\]$/;
103
106
  const isArray = arrayBracketRegexp.test(rawAttributeType);
104
- const attributeType = rawAttributeType.replace(arrayBracketRegexp, '');
107
+ const _attributeType = rawAttributeType.replace(arrayBracketRegexp, '');
108
+ const attributeType = /uuid$/.test(rawAttributeName) ? 'uuid' : _attributeType;
105
109
  return { attributeName, attributeType, isArray, enumValues };
106
110
  }
107
111
  function processAttributeByType({ attributeType, attributeName, isArray, enumValues, dotNotationVariable, fullyQualifiedModelName, attributeData, }) {
108
- switch (attributeType) {
109
- case 'belongs_to':
112
+ const sanitizedAttributeType = camelize(attributeType).toLowerCase();
113
+ switch (sanitizedAttributeType) {
114
+ case 'belongsto':
110
115
  // belongs_to relationships are NOT included in comparable attributes or create/update data
111
116
  // They are only tracked for original values in update contexts
112
117
  break;
@@ -135,6 +140,9 @@ function processAttributeByType({ attributeType, attributeName, isArray, enumVal
135
140
  case 'datetime':
136
141
  processDateTimeAttribute({ attributeName, isArray, dotNotationVariable, attributeData });
137
142
  break;
143
+ case 'uuid':
144
+ processUuidAttribute({ attributeName, isArray, dotNotationVariable, attributeData });
145
+ break;
138
146
  }
139
147
  }
140
148
  function processEnumAttribute({ attributeName, isArray, enumValues, dotNotationVariable, attributeData, }) {
@@ -190,6 +198,21 @@ function processDateTimeAttribute({ attributeName, isArray, dotNotationVariable,
190
198
  attributeData.expectEqualOriginalValue.push(`expect(${dotNotationVariable}${isArray ? '[0]' : ''}).toEqualDateTime(now)`);
191
199
  attributeData.expectEqualUpdatedValue.push(`expect(${dotNotationVariable}${isArray ? '[0]' : ''}).toEqualDateTime(lastHour)`);
192
200
  }
201
+ function processUuidAttribute({ attributeName, isArray, dotNotationVariable, attributeData, }) {
202
+ attributeData.uuidAttributeIncluded = true;
203
+ if (isArray) {
204
+ attributeData.uuidArrayAttributes.push(attributeName);
205
+ }
206
+ const newUuidVariableName = `new${capitalize(attributeName)}`;
207
+ // For arrays, the variable itself is an array, so we use it directly without brackets
208
+ const uuidValue = attributeName;
209
+ const newUuidValue = newUuidVariableName;
210
+ attributeData.attributeCreationKeyValues.push(`${attributeName}: ${uuidValue},`);
211
+ attributeData.attributeUpdateKeyValues.push(`${attributeName}: ${newUuidValue},`);
212
+ attributeData.comparableOriginalAttributeKeyValues.push(`${attributeName}: ${dotNotationVariable},`);
213
+ attributeData.expectEqualOriginalValue.push(`expect(${dotNotationVariable}).toEqual(${attributeName})`);
214
+ attributeData.expectEqualUpdatedValue.push(`expect(${dotNotationVariable}).toEqual(${newUuidVariableName})`);
215
+ }
193
216
  function addOriginalValueTracking(attributeType, attributeName, isArray, dotNotationVariable, attributeData) {
194
217
  const hardToCompareArray = (attributeType === 'date' || attributeType === 'datetime') && isArray;
195
218
  // Exclude belongs_to relationships from original value tracking
@@ -199,7 +222,7 @@ function addOriginalValueTracking(attributeType, attributeName, isArray, dotNota
199
222
  attributeData.expectEqualOriginalNamedVariable.push(`expect(${dotNotationVariable}).toEqual(${originalAttributeVariableName})`);
200
223
  }
201
224
  }
202
- function generateImportStatements(modelConfig, dreamImports, owningModel) {
225
+ function generateImportStatements(modelConfig, dreamImports, owningModel, uuidAttributeIncluded) {
203
226
  const importStatements = compact([
204
227
  importStatementForModel(modelConfig.fullyQualifiedModelName),
205
228
  importStatementForModel(modelConfig.userModelName),
@@ -208,10 +231,11 @@ function generateImportStatements(modelConfig, dreamImports, owningModel) {
208
231
  importStatementForModelFactory(modelConfig.userModelName),
209
232
  owningModel ? importStatementForModelFactory(owningModel) : undefined,
210
233
  ]);
234
+ const cryptoImportLine = uuidAttributeIncluded ? `import { randomUUID } from 'node:crypto'\n` : '';
211
235
  const dreamImportLine = dreamImports.length
212
236
  ? `import { ${uniq(dreamImports).join(', ')} } from '@rvoh/dream'\n`
213
237
  : '';
214
- return `${dreamImportLine}${uniq(importStatements).join('\n')}
238
+ return `${cryptoImportLine}${dreamImportLine}${uniq(importStatements).join('\n')}
215
239
  import { RequestBody, session, SpecRequestType } from '@spec/unit/helpers/${addImportSuffix('authentication.js')}'`;
216
240
  }
217
241
  function generateSpecTemplate(options) {
@@ -312,13 +336,28 @@ function generateCreateActionSpec(options) {
312
336
  return '';
313
337
  const { path, pathParams, modelConfig, fullyQualifiedModelName, forAdmin, singular, attributeData } = options;
314
338
  const subjectFunctionName = `create${modelConfig.modelClassName}`;
315
- const dateTimeSetup = `${attributeData.dateAttributeIncluded
339
+ const uuidAttributeNames = attributeData.attributeCreationKeyValues
340
+ .map(kv => {
341
+ const match = kv.match(/^(\w+):\s*\1,?$/);
342
+ return match?.[1];
343
+ })
344
+ .filter((name) => Boolean(name));
345
+ const uuidSetup = uuidAttributeNames
346
+ .map(attrName => {
347
+ const isArray = attributeData.uuidArrayAttributes.includes(attrName);
348
+ return isArray ? `const ${attrName} = [randomUUID()]` : `const ${attrName} = randomUUID()`;
349
+ })
350
+ .join('\n ');
351
+ const dateTimeSetup = `${uuidSetup
352
+ ? `
353
+ ${uuidSetup}`
354
+ : ''}${attributeData.dateAttributeIncluded
316
355
  ? `
317
356
  const today = CalendarDate.today()`
318
357
  : ''}${attributeData.datetimeAttributeIncluded
319
358
  ? `
320
359
  const now = DateTime.now()`
321
- : ''}${attributeData.dateAttributeIncluded || attributeData.datetimeAttributeIncluded ? '\n' : ''}`;
360
+ : ''}${uuidSetup || attributeData.dateAttributeIncluded || attributeData.datetimeAttributeIncluded ? '\n' : ''}`;
322
361
  const modelQuery = forAdmin
323
362
  ? `${modelConfig.modelClassName}.firstOrFail()`
324
363
  : `${modelConfig.owningModelVariableName}.associationQuery('${singular ? modelConfig.modelVariableName : pluralize(modelConfig.modelVariableName)}').firstOrFail()`;
@@ -354,7 +393,28 @@ function generateUpdateActionSpec(options) {
354
393
  return '';
355
394
  const { path, pathParams, modelConfig, fullyQualifiedModelName, singular, forAdmin, attributeData } = options;
356
395
  const subjectFunctionName = `update${modelConfig.modelClassName}`;
357
- const dateTimeSetup = `${attributeData.dateAttributeIncluded
396
+ const uuidAttributeNames = attributeData.attributeUpdateKeyValues
397
+ .filter(kv => {
398
+ const match = kv.match(/^(\w+):\s*new([A-Z]\w+),?$/);
399
+ return match && match[1] && camelize(match[1]) === camelize(match[2]);
400
+ })
401
+ .map(kv => {
402
+ const match = kv.match(/^(\w+):/);
403
+ return match?.[1];
404
+ })
405
+ .filter(Boolean);
406
+ const uuidSetup = uuidAttributeNames
407
+ .map(attrName => {
408
+ const isArray = attributeData.uuidArrayAttributes.includes(attrName);
409
+ return isArray
410
+ ? `const new${capitalize(attrName)} = [randomUUID()]`
411
+ : `const new${capitalize(attrName)} = randomUUID()`;
412
+ })
413
+ .join('\n ');
414
+ const dateTimeSetup = `${uuidSetup
415
+ ? `
416
+ ${uuidSetup}`
417
+ : ''}${attributeData.dateAttributeIncluded
358
418
  ? `
359
419
  const yesterday = CalendarDate.yesterday()`
360
420
  : ''}${attributeData.datetimeAttributeIncluded
@@ -398,7 +458,21 @@ function generateUpdateActionSpec(options) {
398
458
  ${attributeData.originalValueVariableAssignments.length ? attributeData.originalValueVariableAssignments.join('\n ') : ''}
399
459
 
400
460
  await ${subjectFunctionName}(${modelConfig.modelVariableName}, {
401
- ${attributeData.attributeUpdateKeyValues.length ? attributeData.attributeUpdateKeyValues.join('\n ') : ''}
461
+ ${attributeData.attributeUpdateKeyValues.length
462
+ ? attributeData.attributeUpdateKeyValues
463
+ .map(kv => {
464
+ const match = kv.match(/^(\w+):\s*new([A-Z]\w+),?$/);
465
+ if (match && match[1]) {
466
+ const attrName = match[1];
467
+ const isArray = attributeData.uuidArrayAttributes.includes(attrName);
468
+ return isArray
469
+ ? kv.replace(/\bnew[A-Z]\w+\b/g, '[randomUUID()]')
470
+ : kv.replace(/\bnew[A-Z]\w+\b/g, 'randomUUID()');
471
+ }
472
+ return kv;
473
+ })
474
+ .join('\n ')
475
+ : ''}
402
476
  }, 404)
403
477
 
404
478
  await ${modelConfig.modelVariableName}.reload()
@@ -8,7 +8,7 @@ export default async function installOpenapiTypescript() {
8
8
  try {
9
9
  await DreamCLI.spawn(cmd);
10
10
  }
11
- catch (err) {
11
+ catch {
12
12
  console.log(`Failed to install openapi-typescript as a dev dependency. Please make sure the following command succeeds:
13
13
 
14
14
  "${cmd}"
@@ -298,6 +298,7 @@ function associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDesce
298
298
  if (associatedClass === undefined) {
299
299
  let serializerCheck;
300
300
  try {
301
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
301
302
  ;
302
303
  DataTypeForOpenapi?.prototype?.serializers;
303
304
  }
@@ -57,7 +57,7 @@ export default class OpenapiAppRenderer {
57
57
  openapi: '3.1.0',
58
58
  info: {
59
59
  version: openapiConfig?.info?.version || 'unknown version',
60
- title: openapiConfig?.info?.title || 'unknown title',
60
+ title: openapiConfig?.info?.title || `${psychicApp.appName} | ${openapiName}`,
61
61
  description: openapiConfig?.info?.description || 'The autogenerated openapi spec for your app',
62
62
  },
63
63
  paths: {},