@opensaas/stack-cli 0.3.0 → 0.5.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +193 -0
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +4 -13
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/migrate.d.ts +9 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +473 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/generator/context.d.ts.map +1 -1
- package/dist/generator/context.js +20 -5
- package/dist/generator/context.js.map +1 -1
- package/dist/generator/index.d.ts +1 -1
- package/dist/generator/index.d.ts.map +1 -1
- package/dist/generator/index.js +1 -1
- package/dist/generator/index.js.map +1 -1
- package/dist/generator/lists.d.ts.map +1 -1
- package/dist/generator/lists.js +33 -1
- package/dist/generator/lists.js.map +1 -1
- package/dist/generator/prisma-extensions.d.ts +11 -0
- package/dist/generator/prisma-extensions.d.ts.map +1 -0
- package/dist/generator/prisma-extensions.js +134 -0
- package/dist/generator/prisma-extensions.js.map +1 -0
- package/dist/generator/prisma.d.ts.map +1 -1
- package/dist/generator/prisma.js +4 -0
- package/dist/generator/prisma.js.map +1 -1
- package/dist/generator/types.d.ts.map +1 -1
- package/dist/generator/types.js +151 -17
- package/dist/generator/types.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/lib/documentation-provider.d.ts +23 -0
- package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
- package/dist/mcp/lib/documentation-provider.js +471 -0
- package/dist/mcp/lib/documentation-provider.js.map +1 -1
- package/dist/mcp/lib/wizards/migration-wizard.d.ts +80 -0
- package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -0
- package/dist/mcp/lib/wizards/migration-wizard.js +499 -0
- package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -0
- package/dist/mcp/server/index.d.ts.map +1 -1
- package/dist/mcp/server/index.js +103 -0
- package/dist/mcp/server/index.js.map +1 -1
- package/dist/mcp/server/stack-mcp-server.d.ts +85 -0
- package/dist/mcp/server/stack-mcp-server.d.ts.map +1 -1
- package/dist/mcp/server/stack-mcp-server.js +219 -0
- package/dist/mcp/server/stack-mcp-server.js.map +1 -1
- package/dist/migration/generators/migration-generator.d.ts +60 -0
- package/dist/migration/generators/migration-generator.d.ts.map +1 -0
- package/dist/migration/generators/migration-generator.js +510 -0
- package/dist/migration/generators/migration-generator.js.map +1 -0
- package/dist/migration/introspectors/index.d.ts +12 -0
- package/dist/migration/introspectors/index.d.ts.map +1 -0
- package/dist/migration/introspectors/index.js +10 -0
- package/dist/migration/introspectors/index.js.map +1 -0
- package/dist/migration/introspectors/keystone-introspector.d.ts +59 -0
- package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -0
- package/dist/migration/introspectors/keystone-introspector.js +229 -0
- package/dist/migration/introspectors/keystone-introspector.js.map +1 -0
- package/dist/migration/introspectors/nextjs-introspector.d.ts +59 -0
- package/dist/migration/introspectors/nextjs-introspector.d.ts.map +1 -0
- package/dist/migration/introspectors/nextjs-introspector.js +159 -0
- package/dist/migration/introspectors/nextjs-introspector.js.map +1 -0
- package/dist/migration/introspectors/prisma-introspector.d.ts +45 -0
- package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -0
- package/dist/migration/introspectors/prisma-introspector.js +190 -0
- package/dist/migration/introspectors/prisma-introspector.js.map +1 -0
- package/dist/migration/types.d.ts +86 -0
- package/dist/migration/types.d.ts.map +1 -0
- package/dist/migration/types.js +5 -0
- package/dist/migration/types.js.map +1 -0
- package/package.json +12 -9
- package/src/commands/__snapshots__/generate.test.ts.snap +92 -21
- package/src/commands/generate.ts +8 -19
- package/src/commands/migrate.ts +534 -0
- package/src/generator/__snapshots__/context.test.ts.snap +60 -15
- package/src/generator/__snapshots__/types.test.ts.snap +689 -95
- package/src/generator/context.test.ts +3 -1
- package/src/generator/context.ts +20 -5
- package/src/generator/index.ts +1 -1
- package/src/generator/lists.ts +39 -1
- package/src/generator/prisma-extensions.ts +159 -0
- package/src/generator/prisma.ts +5 -0
- package/src/generator/types.ts +204 -17
- package/src/index.ts +4 -0
- package/src/mcp/lib/documentation-provider.ts +507 -0
- package/src/mcp/lib/wizards/migration-wizard.ts +584 -0
- package/src/mcp/server/index.ts +121 -0
- package/src/mcp/server/stack-mcp-server.ts +243 -0
- package/src/migration/generators/migration-generator.ts +675 -0
- package/src/migration/introspectors/index.ts +12 -0
- package/src/migration/introspectors/keystone-introspector.ts +296 -0
- package/src/migration/introspectors/nextjs-introspector.ts +209 -0
- package/src/migration/introspectors/prisma-introspector.ts +233 -0
- package/src/migration/types.ts +92 -0
- package/tests/introspectors/keystone-introspector.test.ts +255 -0
- package/tests/introspectors/nextjs-introspector.test.ts +302 -0
- package/tests/introspectors/prisma-introspector.test.ts +268 -0
- package/tests/migration-generator.test.ts +592 -0
- package/tests/migration-wizard.test.ts +442 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/generator/type-patcher.d.ts +0 -13
- package/dist/generator/type-patcher.d.ts.map +0 -1
- package/dist/generator/type-patcher.js +0 -68
- package/dist/generator/type-patcher.js.map +0 -1
- package/src/generator/type-patcher.ts +0 -93
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma Schema Introspector
|
|
3
|
+
*
|
|
4
|
+
* Parses prisma/schema.prisma and extracts structured information
|
|
5
|
+
* about models, fields, relationships, and enums.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs-extra'
|
|
9
|
+
import path from 'path'
|
|
10
|
+
import type { IntrospectedSchema, IntrospectedModel, IntrospectedField } from '../types.js'
|
|
11
|
+
|
|
12
|
+
export class PrismaIntrospector {
|
|
13
|
+
/**
|
|
14
|
+
* Introspect a Prisma schema file
|
|
15
|
+
*/
|
|
16
|
+
async introspect(
|
|
17
|
+
cwd: string,
|
|
18
|
+
schemaPath: string = 'prisma/schema.prisma',
|
|
19
|
+
): Promise<IntrospectedSchema> {
|
|
20
|
+
const fullPath = path.isAbsolute(schemaPath) ? schemaPath : path.join(cwd, schemaPath)
|
|
21
|
+
|
|
22
|
+
if (!(await fs.pathExists(fullPath))) {
|
|
23
|
+
throw new Error(`Schema file not found: ${fullPath}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const schema = await fs.readFile(fullPath, 'utf-8')
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
provider: this.extractProvider(schema),
|
|
30
|
+
models: this.extractModels(schema),
|
|
31
|
+
enums: this.extractEnums(schema),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract database provider from datasource block
|
|
37
|
+
*/
|
|
38
|
+
private extractProvider(schema: string): string {
|
|
39
|
+
const match = schema.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/)
|
|
40
|
+
return match ? match[1] : 'unknown'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract all model definitions
|
|
45
|
+
*/
|
|
46
|
+
private extractModels(schema: string): IntrospectedModel[] {
|
|
47
|
+
const models: IntrospectedModel[] = []
|
|
48
|
+
|
|
49
|
+
// Match model blocks
|
|
50
|
+
const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g
|
|
51
|
+
let match
|
|
52
|
+
|
|
53
|
+
while ((match = modelRegex.exec(schema)) !== null) {
|
|
54
|
+
const name = match[1]
|
|
55
|
+
const body = match[2]
|
|
56
|
+
|
|
57
|
+
const fields = this.extractFields(body)
|
|
58
|
+
const primaryKey = fields.find((f) => f.isId)?.name || 'id'
|
|
59
|
+
|
|
60
|
+
models.push({
|
|
61
|
+
name,
|
|
62
|
+
fields,
|
|
63
|
+
hasRelations: fields.some((f) => f.relation !== undefined),
|
|
64
|
+
primaryKey,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return models
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract fields from a model body
|
|
73
|
+
*/
|
|
74
|
+
private extractFields(body: string): IntrospectedField[] {
|
|
75
|
+
const fields: IntrospectedField[] = []
|
|
76
|
+
const lines = body.split('\n')
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.trim()
|
|
80
|
+
|
|
81
|
+
// Skip empty lines, comments, and model-level attributes
|
|
82
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const field = this.parseFieldLine(trimmed)
|
|
87
|
+
if (field) {
|
|
88
|
+
fields.push(field)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return fields
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a single field line
|
|
97
|
+
*/
|
|
98
|
+
private parseFieldLine(line: string): IntrospectedField | null {
|
|
99
|
+
// Basic field pattern: name Type modifiers attributes
|
|
100
|
+
// Examples:
|
|
101
|
+
// id String @id @default(cuid())
|
|
102
|
+
// title String
|
|
103
|
+
// isActive Boolean? @default(true)
|
|
104
|
+
// posts Post[]
|
|
105
|
+
// author User @relation(fields: [authorId], references: [id])
|
|
106
|
+
|
|
107
|
+
// Remove comments
|
|
108
|
+
const withoutComment = line.split('//')[0].trim()
|
|
109
|
+
|
|
110
|
+
// Match field name and type
|
|
111
|
+
const fieldMatch = withoutComment.match(/^(\w+)\s+(\w+)(\?)?(\[\])?(.*)$/)
|
|
112
|
+
if (!fieldMatch) return null
|
|
113
|
+
|
|
114
|
+
const [, name, rawType, optional, isList, rest] = fieldMatch
|
|
115
|
+
|
|
116
|
+
// Skip if this looks like an index or other non-field line
|
|
117
|
+
if (['@@', 'index', 'unique'].some((kw) => name.startsWith(kw))) {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const field: IntrospectedField = {
|
|
122
|
+
name,
|
|
123
|
+
type: rawType,
|
|
124
|
+
isRequired: !optional,
|
|
125
|
+
isUnique: rest.includes('@unique'),
|
|
126
|
+
isId: rest.includes('@id'),
|
|
127
|
+
isList: !!isList,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Extract default value (handle nested parentheses)
|
|
131
|
+
const defaultMatch = rest.match(/@default\(/)
|
|
132
|
+
if (defaultMatch) {
|
|
133
|
+
const startIdx = rest.indexOf('@default(') + '@default('.length
|
|
134
|
+
let depth = 1
|
|
135
|
+
let endIdx = startIdx
|
|
136
|
+
|
|
137
|
+
while (depth > 0 && endIdx < rest.length) {
|
|
138
|
+
if (rest[endIdx] === '(') depth++
|
|
139
|
+
else if (rest[endIdx] === ')') depth--
|
|
140
|
+
if (depth > 0) endIdx++
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
field.defaultValue = rest.substring(startIdx, endIdx)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Extract relation
|
|
147
|
+
const relationMatch = rest.match(/@relation\(([^)]+)\)/)
|
|
148
|
+
if (relationMatch) {
|
|
149
|
+
const relationBody = relationMatch[1]
|
|
150
|
+
|
|
151
|
+
// Parse relation parts
|
|
152
|
+
const fieldsMatch = relationBody.match(/fields:\s*\[([^\]]+)\]/)
|
|
153
|
+
const referencesMatch = relationBody.match(/references:\s*\[([^\]]+)\]/)
|
|
154
|
+
const nameMatch = relationBody.match(/name:\s*"([^"]+)"/) || relationBody.match(/"([^"]+)"/)
|
|
155
|
+
|
|
156
|
+
field.relation = {
|
|
157
|
+
name: nameMatch ? nameMatch[1] : '',
|
|
158
|
+
model: rawType,
|
|
159
|
+
fields: fieldsMatch ? fieldsMatch[1].split(',').map((f) => f.trim()) : [],
|
|
160
|
+
references: referencesMatch ? referencesMatch[1].split(',').map((r) => r.trim()) : [],
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return field
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Extract enum definitions
|
|
169
|
+
*/
|
|
170
|
+
private extractEnums(schema: string): Array<{ name: string; values: string[] }> {
|
|
171
|
+
const enums: Array<{ name: string; values: string[] }> = []
|
|
172
|
+
|
|
173
|
+
// Match enum blocks
|
|
174
|
+
const enumRegex = /enum\s+(\w+)\s*\{([^}]+)\}/g
|
|
175
|
+
let match
|
|
176
|
+
|
|
177
|
+
while ((match = enumRegex.exec(schema)) !== null) {
|
|
178
|
+
const name = match[1]
|
|
179
|
+
const body = match[2]
|
|
180
|
+
|
|
181
|
+
const values = body
|
|
182
|
+
.split('\n')
|
|
183
|
+
.map((line) => line.trim())
|
|
184
|
+
.filter((line) => line && !line.startsWith('//'))
|
|
185
|
+
|
|
186
|
+
enums.push({ name, values })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return enums
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Map Prisma type to OpenSaaS field type
|
|
194
|
+
*/
|
|
195
|
+
mapPrismaTypeToOpenSaas(prismaType: string): { type: string; import: string } {
|
|
196
|
+
const mappings: Record<string, { type: string; import: string }> = {
|
|
197
|
+
String: { type: 'text', import: 'text' },
|
|
198
|
+
Int: { type: 'integer', import: 'integer' },
|
|
199
|
+
Float: { type: 'float', import: 'float' },
|
|
200
|
+
Boolean: { type: 'checkbox', import: 'checkbox' },
|
|
201
|
+
DateTime: { type: 'timestamp', import: 'timestamp' },
|
|
202
|
+
Json: { type: 'json', import: 'json' },
|
|
203
|
+
BigInt: { type: 'text', import: 'text' }, // No native support
|
|
204
|
+
Decimal: { type: 'text', import: 'text' }, // No native support
|
|
205
|
+
Bytes: { type: 'text', import: 'text' }, // No native support
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return mappings[prismaType] || { type: 'text', import: 'text' }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get warnings for unsupported features
|
|
213
|
+
*/
|
|
214
|
+
getWarnings(schema: IntrospectedSchema): string[] {
|
|
215
|
+
const warnings: string[] = []
|
|
216
|
+
|
|
217
|
+
// Check for unsupported types
|
|
218
|
+
for (const model of schema.models) {
|
|
219
|
+
for (const field of model.fields) {
|
|
220
|
+
if (['BigInt', 'Decimal', 'Bytes'].includes(field.type)) {
|
|
221
|
+
warnings.push(
|
|
222
|
+
`Field "${model.name}.${field.name}" uses unsupported type "${field.type}" - will be mapped to text()`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check for composite IDs
|
|
229
|
+
// This would require checking for @@id in the original schema
|
|
230
|
+
|
|
231
|
+
return warnings
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration types - Shared types for the migration system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type ProjectType = 'prisma' | 'nextjs' | 'keystone'
|
|
6
|
+
|
|
7
|
+
export interface ModelInfo {
|
|
8
|
+
name: string
|
|
9
|
+
fieldCount: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProjectAnalysis {
|
|
13
|
+
projectTypes: ProjectType[]
|
|
14
|
+
cwd: string
|
|
15
|
+
models?: ModelInfo[]
|
|
16
|
+
provider?: string
|
|
17
|
+
hasAuth?: boolean
|
|
18
|
+
authLibrary?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FieldMapping {
|
|
22
|
+
prismaType: string
|
|
23
|
+
opensaasType: string
|
|
24
|
+
opensaasImport: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MigrationQuestion {
|
|
28
|
+
id: string
|
|
29
|
+
text: string
|
|
30
|
+
type: 'text' | 'select' | 'boolean' | 'multiselect'
|
|
31
|
+
options?: string[]
|
|
32
|
+
defaultValue?: string | boolean | string[]
|
|
33
|
+
required?: boolean
|
|
34
|
+
dependsOn?: {
|
|
35
|
+
questionId: string
|
|
36
|
+
value: string | boolean
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MigrationSession {
|
|
41
|
+
id: string
|
|
42
|
+
projectType: ProjectType
|
|
43
|
+
analysis: ProjectAnalysis
|
|
44
|
+
currentQuestionIndex: number
|
|
45
|
+
answers: Record<string, string | boolean | string[]>
|
|
46
|
+
generatedConfig?: string
|
|
47
|
+
isComplete: boolean
|
|
48
|
+
createdAt: Date
|
|
49
|
+
updatedAt: Date
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MigrationOutput {
|
|
53
|
+
configContent: string
|
|
54
|
+
dependencies: string[]
|
|
55
|
+
files: Array<{
|
|
56
|
+
path: string
|
|
57
|
+
content: string
|
|
58
|
+
language: string
|
|
59
|
+
description: string
|
|
60
|
+
}>
|
|
61
|
+
steps: string[]
|
|
62
|
+
warnings: string[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IntrospectedModel {
|
|
66
|
+
name: string
|
|
67
|
+
fields: IntrospectedField[]
|
|
68
|
+
hasRelations: boolean
|
|
69
|
+
primaryKey: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface IntrospectedField {
|
|
73
|
+
name: string
|
|
74
|
+
type: string
|
|
75
|
+
isRequired: boolean
|
|
76
|
+
isUnique: boolean
|
|
77
|
+
isId: boolean
|
|
78
|
+
isList: boolean
|
|
79
|
+
defaultValue?: string
|
|
80
|
+
relation?: {
|
|
81
|
+
name: string
|
|
82
|
+
model: string
|
|
83
|
+
fields: string[]
|
|
84
|
+
references: string[]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface IntrospectedSchema {
|
|
89
|
+
provider: string
|
|
90
|
+
models: IntrospectedModel[]
|
|
91
|
+
enums: Array<{ name: string; values: string[] }>
|
|
92
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { KeystoneIntrospector } from '../../src/migration/introspectors/keystone-introspector.js'
|
|
3
|
+
import fs from 'fs-extra'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import os from 'os'
|
|
6
|
+
|
|
7
|
+
describe('KeystoneIntrospector', () => {
|
|
8
|
+
let introspector: KeystoneIntrospector
|
|
9
|
+
let tempDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
introspector = new KeystoneIntrospector()
|
|
13
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'keystone-test-'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.remove(tempDir)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should load and parse a KeystoneJS config', async () => {
|
|
21
|
+
const config = `
|
|
22
|
+
export default {
|
|
23
|
+
db: {
|
|
24
|
+
provider: 'sqlite',
|
|
25
|
+
url: 'file:./keystone.db',
|
|
26
|
+
},
|
|
27
|
+
lists: {
|
|
28
|
+
User: {
|
|
29
|
+
fields: {
|
|
30
|
+
name: {
|
|
31
|
+
type: 'text',
|
|
32
|
+
validation: { isRequired: true },
|
|
33
|
+
},
|
|
34
|
+
email: {
|
|
35
|
+
type: 'text',
|
|
36
|
+
validation: { isRequired: true },
|
|
37
|
+
},
|
|
38
|
+
posts: {
|
|
39
|
+
type: 'relationship',
|
|
40
|
+
ref: 'Post.author',
|
|
41
|
+
many: true,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
Post: {
|
|
46
|
+
fields: {
|
|
47
|
+
title: {
|
|
48
|
+
type: 'text',
|
|
49
|
+
validation: { isRequired: true },
|
|
50
|
+
},
|
|
51
|
+
author: {
|
|
52
|
+
type: 'relationship',
|
|
53
|
+
ref: 'User.posts',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
`
|
|
60
|
+
await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
|
|
61
|
+
|
|
62
|
+
const result = await introspector.introspect(tempDir, 'keystone.config.js')
|
|
63
|
+
|
|
64
|
+
expect(result.provider).toBe('sqlite')
|
|
65
|
+
expect(result.models).toHaveLength(2)
|
|
66
|
+
|
|
67
|
+
const user = result.models.find((m) => m.name === 'User')
|
|
68
|
+
expect(user).toBeDefined()
|
|
69
|
+
expect(user!.fields).toHaveLength(3)
|
|
70
|
+
|
|
71
|
+
const post = result.models.find((m) => m.name === 'Post')
|
|
72
|
+
expect(post).toBeDefined()
|
|
73
|
+
expect(post!.hasRelations).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should map KeystoneJS types to OpenSaaS types', () => {
|
|
77
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('text')).toEqual({ type: 'text', import: 'text' })
|
|
78
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('integer')).toEqual({
|
|
79
|
+
type: 'integer',
|
|
80
|
+
import: 'integer',
|
|
81
|
+
})
|
|
82
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('checkbox')).toEqual({
|
|
83
|
+
type: 'checkbox',
|
|
84
|
+
import: 'checkbox',
|
|
85
|
+
})
|
|
86
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('timestamp')).toEqual({
|
|
87
|
+
type: 'timestamp',
|
|
88
|
+
import: 'timestamp',
|
|
89
|
+
})
|
|
90
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('relationship')).toEqual({
|
|
91
|
+
type: 'relationship',
|
|
92
|
+
import: 'relationship',
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should handle file and image fields', () => {
|
|
97
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('image')).toEqual({
|
|
98
|
+
type: 'image',
|
|
99
|
+
import: 'image',
|
|
100
|
+
})
|
|
101
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('file')).toEqual({ type: 'file', import: 'file' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should handle virtual fields', () => {
|
|
105
|
+
expect(introspector.mapKeystoneTypeToOpenSaas('virtual')).toEqual({
|
|
106
|
+
type: 'virtual',
|
|
107
|
+
import: 'virtual',
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should generate warnings for migration reminders', async () => {
|
|
112
|
+
const config = `
|
|
113
|
+
export default {
|
|
114
|
+
db: {
|
|
115
|
+
provider: 'postgresql',
|
|
116
|
+
},
|
|
117
|
+
lists: {
|
|
118
|
+
Media: {
|
|
119
|
+
fields: {
|
|
120
|
+
image: {
|
|
121
|
+
type: 'image',
|
|
122
|
+
},
|
|
123
|
+
document: {
|
|
124
|
+
type: 'file',
|
|
125
|
+
},
|
|
126
|
+
computed: {
|
|
127
|
+
type: 'virtual',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
`
|
|
134
|
+
await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
|
|
135
|
+
|
|
136
|
+
const result = await introspector.introspect(tempDir, 'keystone.config.js')
|
|
137
|
+
const warnings = introspector.getWarnings(result)
|
|
138
|
+
|
|
139
|
+
expect(warnings.length).toBeGreaterThan(0)
|
|
140
|
+
// Should warn about storage configuration (helpful reminder)
|
|
141
|
+
expect(warnings.some((w) => w.includes('storage'))).toBe(true)
|
|
142
|
+
// Should remind about manual migration for virtual field hooks
|
|
143
|
+
expect(warnings.some((w) => w.includes('virtual') && w.includes('manually migrate'))).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should throw for missing config', async () => {
|
|
147
|
+
await expect(introspector.introspect(tempDir, 'nonexistent.ts')).rejects.toThrow(
|
|
148
|
+
'KeystoneJS config not found',
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('should try alternative config paths', async () => {
|
|
153
|
+
const config = `
|
|
154
|
+
export default {
|
|
155
|
+
db: {
|
|
156
|
+
provider: 'sqlite',
|
|
157
|
+
},
|
|
158
|
+
lists: {
|
|
159
|
+
User: {
|
|
160
|
+
fields: {
|
|
161
|
+
name: {
|
|
162
|
+
type: 'text',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
`
|
|
169
|
+
// Create config at alternative path
|
|
170
|
+
await fs.writeFile(path.join(tempDir, 'keystone.ts'), config)
|
|
171
|
+
|
|
172
|
+
// Should find it even if we specify the default path
|
|
173
|
+
const result = await introspector.introspect(tempDir)
|
|
174
|
+
|
|
175
|
+
expect(result.models).toHaveLength(1)
|
|
176
|
+
expect(result.models[0].name).toBe('User')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should parse relationship fields correctly', async () => {
|
|
180
|
+
const config = `
|
|
181
|
+
export default {
|
|
182
|
+
db: {
|
|
183
|
+
provider: 'sqlite',
|
|
184
|
+
},
|
|
185
|
+
lists: {
|
|
186
|
+
Post: {
|
|
187
|
+
fields: {
|
|
188
|
+
author: {
|
|
189
|
+
type: 'relationship',
|
|
190
|
+
ref: 'User.posts',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
User: {
|
|
195
|
+
fields: {
|
|
196
|
+
posts: {
|
|
197
|
+
type: 'relationship',
|
|
198
|
+
ref: 'Post.author',
|
|
199
|
+
many: true,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
`
|
|
206
|
+
await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
|
|
207
|
+
|
|
208
|
+
const result = await introspector.introspect(tempDir, 'keystone.config.js')
|
|
209
|
+
|
|
210
|
+
const post = result.models.find((m) => m.name === 'Post')
|
|
211
|
+
const authorField = post!.fields.find((f) => f.name === 'author')
|
|
212
|
+
expect(authorField!.relation).toBeDefined()
|
|
213
|
+
expect(authorField!.relation!.model).toBe('User')
|
|
214
|
+
expect(authorField!.relation!.references).toEqual(['posts'])
|
|
215
|
+
|
|
216
|
+
const user = result.models.find((m) => m.name === 'User')
|
|
217
|
+
const postsField = user!.fields.find((f) => f.name === 'posts')
|
|
218
|
+
expect(postsField!.isList).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('should handle field validation options', async () => {
|
|
222
|
+
const config = `
|
|
223
|
+
export default {
|
|
224
|
+
db: {
|
|
225
|
+
provider: 'sqlite',
|
|
226
|
+
},
|
|
227
|
+
lists: {
|
|
228
|
+
User: {
|
|
229
|
+
fields: {
|
|
230
|
+
email: {
|
|
231
|
+
type: 'text',
|
|
232
|
+
validation: { isRequired: true },
|
|
233
|
+
isRequired: true,
|
|
234
|
+
},
|
|
235
|
+
age: {
|
|
236
|
+
type: 'integer',
|
|
237
|
+
defaultValue: 0,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
`
|
|
244
|
+
await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
|
|
245
|
+
|
|
246
|
+
const result = await introspector.introspect(tempDir, 'keystone.config.js')
|
|
247
|
+
const user = result.models[0]
|
|
248
|
+
|
|
249
|
+
const emailField = user.fields.find((f) => f.name === 'email')
|
|
250
|
+
expect(emailField!.isRequired).toBe(true)
|
|
251
|
+
|
|
252
|
+
const ageField = user.fields.find((f) => f.name === 'age')
|
|
253
|
+
expect(ageField!.defaultValue).toBe('0')
|
|
254
|
+
})
|
|
255
|
+
})
|