@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.
Files changed (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +193 -0
  3. package/dist/commands/generate.d.ts.map +1 -1
  4. package/dist/commands/generate.js +4 -13
  5. package/dist/commands/generate.js.map +1 -1
  6. package/dist/commands/migrate.d.ts +9 -0
  7. package/dist/commands/migrate.d.ts.map +1 -0
  8. package/dist/commands/migrate.js +473 -0
  9. package/dist/commands/migrate.js.map +1 -0
  10. package/dist/generator/context.d.ts.map +1 -1
  11. package/dist/generator/context.js +20 -5
  12. package/dist/generator/context.js.map +1 -1
  13. package/dist/generator/index.d.ts +1 -1
  14. package/dist/generator/index.d.ts.map +1 -1
  15. package/dist/generator/index.js +1 -1
  16. package/dist/generator/index.js.map +1 -1
  17. package/dist/generator/lists.d.ts.map +1 -1
  18. package/dist/generator/lists.js +33 -1
  19. package/dist/generator/lists.js.map +1 -1
  20. package/dist/generator/prisma-extensions.d.ts +11 -0
  21. package/dist/generator/prisma-extensions.d.ts.map +1 -0
  22. package/dist/generator/prisma-extensions.js +134 -0
  23. package/dist/generator/prisma-extensions.js.map +1 -0
  24. package/dist/generator/prisma.d.ts.map +1 -1
  25. package/dist/generator/prisma.js +4 -0
  26. package/dist/generator/prisma.js.map +1 -1
  27. package/dist/generator/types.d.ts.map +1 -1
  28. package/dist/generator/types.js +151 -17
  29. package/dist/generator/types.js.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp/lib/documentation-provider.d.ts +23 -0
  33. package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
  34. package/dist/mcp/lib/documentation-provider.js +471 -0
  35. package/dist/mcp/lib/documentation-provider.js.map +1 -1
  36. package/dist/mcp/lib/wizards/migration-wizard.d.ts +80 -0
  37. package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -0
  38. package/dist/mcp/lib/wizards/migration-wizard.js +499 -0
  39. package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -0
  40. package/dist/mcp/server/index.d.ts.map +1 -1
  41. package/dist/mcp/server/index.js +103 -0
  42. package/dist/mcp/server/index.js.map +1 -1
  43. package/dist/mcp/server/stack-mcp-server.d.ts +85 -0
  44. package/dist/mcp/server/stack-mcp-server.d.ts.map +1 -1
  45. package/dist/mcp/server/stack-mcp-server.js +219 -0
  46. package/dist/mcp/server/stack-mcp-server.js.map +1 -1
  47. package/dist/migration/generators/migration-generator.d.ts +60 -0
  48. package/dist/migration/generators/migration-generator.d.ts.map +1 -0
  49. package/dist/migration/generators/migration-generator.js +510 -0
  50. package/dist/migration/generators/migration-generator.js.map +1 -0
  51. package/dist/migration/introspectors/index.d.ts +12 -0
  52. package/dist/migration/introspectors/index.d.ts.map +1 -0
  53. package/dist/migration/introspectors/index.js +10 -0
  54. package/dist/migration/introspectors/index.js.map +1 -0
  55. package/dist/migration/introspectors/keystone-introspector.d.ts +59 -0
  56. package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -0
  57. package/dist/migration/introspectors/keystone-introspector.js +229 -0
  58. package/dist/migration/introspectors/keystone-introspector.js.map +1 -0
  59. package/dist/migration/introspectors/nextjs-introspector.d.ts +59 -0
  60. package/dist/migration/introspectors/nextjs-introspector.d.ts.map +1 -0
  61. package/dist/migration/introspectors/nextjs-introspector.js +159 -0
  62. package/dist/migration/introspectors/nextjs-introspector.js.map +1 -0
  63. package/dist/migration/introspectors/prisma-introspector.d.ts +45 -0
  64. package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -0
  65. package/dist/migration/introspectors/prisma-introspector.js +190 -0
  66. package/dist/migration/introspectors/prisma-introspector.js.map +1 -0
  67. package/dist/migration/types.d.ts +86 -0
  68. package/dist/migration/types.d.ts.map +1 -0
  69. package/dist/migration/types.js +5 -0
  70. package/dist/migration/types.js.map +1 -0
  71. package/package.json +12 -9
  72. package/src/commands/__snapshots__/generate.test.ts.snap +92 -21
  73. package/src/commands/generate.ts +8 -19
  74. package/src/commands/migrate.ts +534 -0
  75. package/src/generator/__snapshots__/context.test.ts.snap +60 -15
  76. package/src/generator/__snapshots__/types.test.ts.snap +689 -95
  77. package/src/generator/context.test.ts +3 -1
  78. package/src/generator/context.ts +20 -5
  79. package/src/generator/index.ts +1 -1
  80. package/src/generator/lists.ts +39 -1
  81. package/src/generator/prisma-extensions.ts +159 -0
  82. package/src/generator/prisma.ts +5 -0
  83. package/src/generator/types.ts +204 -17
  84. package/src/index.ts +4 -0
  85. package/src/mcp/lib/documentation-provider.ts +507 -0
  86. package/src/mcp/lib/wizards/migration-wizard.ts +584 -0
  87. package/src/mcp/server/index.ts +121 -0
  88. package/src/mcp/server/stack-mcp-server.ts +243 -0
  89. package/src/migration/generators/migration-generator.ts +675 -0
  90. package/src/migration/introspectors/index.ts +12 -0
  91. package/src/migration/introspectors/keystone-introspector.ts +296 -0
  92. package/src/migration/introspectors/nextjs-introspector.ts +209 -0
  93. package/src/migration/introspectors/prisma-introspector.ts +233 -0
  94. package/src/migration/types.ts +92 -0
  95. package/tests/introspectors/keystone-introspector.test.ts +255 -0
  96. package/tests/introspectors/nextjs-introspector.test.ts +302 -0
  97. package/tests/introspectors/prisma-introspector.test.ts +268 -0
  98. package/tests/migration-generator.test.ts +592 -0
  99. package/tests/migration-wizard.test.ts +442 -0
  100. package/tsconfig.tsbuildinfo +1 -1
  101. package/dist/generator/type-patcher.d.ts +0 -13
  102. package/dist/generator/type-patcher.d.ts.map +0 -1
  103. package/dist/generator/type-patcher.js +0 -68
  104. package/dist/generator/type-patcher.js.map +0 -1
  105. package/src/generator/type-patcher.ts +0 -93
@@ -60,7 +60,9 @@ describe('Context Generator', () => {
60
60
  const context = generateContext(config)
61
61
 
62
62
  expect(context).toContain('const globalForPrisma')
63
- expect(context).toContain('globalThis as unknown as { prisma: PrismaClient | null }')
63
+ expect(context).toContain(
64
+ 'globalThis as unknown as { prisma: ReturnType<typeof createExtendedPrisma> | null }',
65
+ )
64
66
  expect(context).toContain('globalForPrisma.prisma')
65
67
  expect(context).toContain("if (process.env.NODE_ENV !== 'production')")
66
68
  })
@@ -123,6 +123,7 @@ import { getContext as getOpensaasContext } from '@opensaas/stack-core'
123
123
  import type { Session as OpensaasSession, OpenSaasConfig } from '@opensaas/stack-core'
124
124
  import { PrismaClient } from './prisma-client/client'
125
125
  import type { Context } from './types'
126
+ import { prismaExtensions } from './prisma-extensions'
126
127
  import configOrPromise from '../opensaas.config'
127
128
 
128
129
  // Resolve config if it's a Promise (when plugins are present)
@@ -130,15 +131,29 @@ const configPromise = Promise.resolve(configOrPromise)
130
131
  let resolvedConfig: OpenSaasConfig | null = null
131
132
 
132
133
  // Internal Prisma singleton - managed automatically
133
- const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | null }
134
- let prisma: PrismaClient | null = null
134
+ const globalForPrisma = globalThis as unknown as { prisma: ReturnType<typeof createExtendedPrisma> | null }
135
+ let prisma: ReturnType<typeof createExtendedPrisma> | null = null
136
+
137
+ /**
138
+ * Create Prisma client with result extensions
139
+ */
140
+ function createExtendedPrisma(basePrisma: PrismaClient) {
141
+ // Check if there are any extensions to apply
142
+ if (Object.keys(prismaExtensions).length === 0) {
143
+ return basePrisma
144
+ }
145
+ // Apply result extensions
146
+ return basePrisma.$extends(prismaExtensions)
147
+ }
135
148
 
136
149
  async function getPrisma() {
137
150
  if (!prisma) {
138
151
  if (!resolvedConfig) {
139
152
  resolvedConfig = await configPromise
140
153
  }
141
- prisma = globalForPrisma.prisma ?? ${prismaInstantiation}
154
+ const basePrisma = ${prismaInstantiation}
155
+ const extendedPrisma = createExtendedPrisma(basePrisma)
156
+ prisma = globalForPrisma.prisma ?? extendedPrisma
142
157
  if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
143
158
  }
144
159
  return prisma
@@ -175,7 +190,7 @@ ${storageUtilities}
175
190
  export async function getContext<TSession extends OpensaasSession = OpensaasSession>(session?: TSession): Promise<Context<TSession>> {
176
191
  const config = await getConfig()
177
192
  const prismaClient = await getPrisma()
178
- return getOpensaasContext(config, prismaClient, session ?? null, storage) as Context<TSession>
193
+ return getOpensaasContext(config, prismaClient, session ?? null, storage) as unknown as Context<TSession>
179
194
  }
180
195
 
181
196
  /**
@@ -185,7 +200,7 @@ export async function getContext<TSession extends OpensaasSession = OpensaasSess
185
200
  export const rawOpensaasContext = (async () => {
186
201
  const config = await getConfig()
187
202
  const prismaClient = await getPrisma()
188
- return getOpensaasContext(config, prismaClient, null, storage)
203
+ return getOpensaasContext(config, prismaClient, null, storage) as unknown as Context
189
204
  })()
190
205
 
191
206
  /**
@@ -2,6 +2,6 @@ export { generatePrismaSchema, writePrismaSchema } from './prisma.js'
2
2
  export { generatePrismaConfig, writePrismaConfig } from './prisma-config.js'
3
3
  export { generateTypes, writeTypes } from './types.js'
4
4
  export { generateListsNamespace, writeLists } from './lists.js'
5
- export { patchPrismaTypes } from './type-patcher.js'
6
5
  export { generateContext, writeContext } from './context.js'
7
6
  export { generatePluginTypes, writePluginTypes } from './plugin-types.js'
7
+ export { generatePrismaExtensions, writePrismaExtensions } from './prisma-extensions.js'
@@ -2,6 +2,25 @@ import type { OpenSaasConfig } from '@opensaas/stack-core'
2
2
  import * as fs from 'fs'
3
3
  import * as path from 'path'
4
4
 
5
+ /**
6
+ * Map field type string to TypeScript field type name
7
+ */
8
+ function getFieldTypeName(fieldType: string): string {
9
+ const typeMap: Record<string, string> = {
10
+ text: 'TextField',
11
+ integer: 'IntegerField',
12
+ checkbox: 'CheckboxField',
13
+ timestamp: 'TimestampField',
14
+ password: 'PasswordField',
15
+ select: 'SelectField',
16
+ relationship: 'RelationshipField',
17
+ json: 'JsonField',
18
+ virtual: 'VirtualField',
19
+ }
20
+
21
+ return typeMap[fieldType] || 'BaseFieldConfig'
22
+ }
23
+
5
24
  /**
6
25
  * Generate Lists namespace with TypeInfo for each list
7
26
  * This provides strongly-typed hooks with Prisma input types
@@ -61,15 +80,34 @@ export function generateListsNamespace(config: OpenSaasConfig): string {
61
80
  lines.push('export declare namespace Lists {')
62
81
 
63
82
  // Generate type for each list
64
- for (const listName of Object.keys(config.lists)) {
83
+ for (const [listName, listConfig] of Object.entries(config.lists)) {
65
84
  lines.push(
66
85
  ` export type ${listName} = import('@opensaas/stack-core').ListConfig<Lists.${listName}.TypeInfo>`,
67
86
  )
68
87
  lines.push('')
69
88
  lines.push(` namespace ${listName} {`)
70
89
  lines.push(` export type Item = import('./types').${listName}`)
90
+ lines.push('')
91
+
92
+ // Generate Fields type
93
+ lines.push(` /**`)
94
+ lines.push(` * Field configurations for ${listName}`)
95
+ lines.push(` * Maps field names to their field config types`)
96
+ lines.push(` */`)
97
+ lines.push(` export type Fields = {`)
98
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
99
+ const fieldTypeName = getFieldTypeName(fieldConfig.type)
100
+ lines.push(
101
+ ` ${fieldName}: import('@opensaas/stack-core').${fieldTypeName}<Lists.${listName}.TypeInfo>`,
102
+ )
103
+ }
104
+ lines.push(` }`)
105
+ lines.push('')
106
+
107
+ // Generate TypeInfo with fields property
71
108
  lines.push(` export type TypeInfo = {`)
72
109
  lines.push(` key: '${listName}'`)
110
+ lines.push(` fields: Fields`)
73
111
  lines.push(` item: Item`)
74
112
  lines.push(` inputs: {`)
75
113
  lines.push(` create: import('./prisma-client/client').Prisma.${listName}CreateInput`)
@@ -0,0 +1,159 @@
1
+ import type { OpenSaasConfig, FieldConfig } from '@opensaas/stack-core'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+
5
+ /**
6
+ * Generate Prisma result extensions configuration
7
+ * This creates a Prisma client extension that calls field resolveOutput hooks
8
+ */
9
+ export function generatePrismaExtensions(config: OpenSaasConfig): string {
10
+ const lines: string[] = []
11
+
12
+ // Add header comment
13
+ lines.push('/**')
14
+ lines.push(' * Generated Prisma result extensions from OpenSaas configuration')
15
+ lines.push(' * DO NOT EDIT - This file is automatically generated')
16
+ lines.push(' */')
17
+ lines.push('')
18
+
19
+ // Add imports
20
+ lines.push("import { Prisma } from './prisma-client/client'")
21
+ lines.push("import configOrPromise from '../opensaas.config'")
22
+ lines.push('')
23
+
24
+ // Resolve config synchronously if possible (will be resolved by context.ts anyway)
25
+ lines.push('// Resolve config - may be a promise if plugins are present')
26
+ lines.push('let resolvedConfig: any = null')
27
+ lines.push('const configPromise = Promise.resolve(configOrPromise)')
28
+ lines.push('configPromise.then(cfg => { resolvedConfig = cfg })')
29
+ lines.push('')
30
+
31
+ // Check if any fields have result extensions or are virtual
32
+ let hasExtensions = false
33
+ for (const listConfig of Object.values(config.lists)) {
34
+ for (const fieldConfig of Object.values(listConfig.fields)) {
35
+ if (fieldConfig.resultExtension || fieldConfig.virtual) {
36
+ hasExtensions = true
37
+ break
38
+ }
39
+ }
40
+ if (hasExtensions) break
41
+ }
42
+
43
+ if (!hasExtensions) {
44
+ // No extensions needed - export a no-op
45
+ lines.push('/**')
46
+ lines.push(' * No result extensions configured')
47
+ lines.push(' */')
48
+ lines.push('export const prismaExtensions = {}')
49
+ lines.push('')
50
+ return lines.join('\n')
51
+ }
52
+
53
+ // Generate result extensions
54
+ lines.push('/**')
55
+ lines.push(' * Prisma result extensions for field transformations and virtual fields')
56
+ lines.push(' * Delegates to field resolveOutput hooks from config for runtime transformations')
57
+ lines.push(' */')
58
+ lines.push('export const prismaExtensions = Prisma.defineExtension({')
59
+ lines.push(' result: {')
60
+
61
+ // Generate extensions for each list
62
+ for (const [listName, listConfig] of Object.entries(config.lists)) {
63
+ // Include both fields with resultExtension AND virtual fields
64
+ const fieldsWithExtensions: Array<[string, FieldConfig]> = Object.entries(
65
+ listConfig.fields,
66
+ ).filter(([_, config]) => config.resultExtension || config.virtual)
67
+
68
+ if (fieldsWithExtensions.length === 0) continue
69
+
70
+ const modelKey = listName.charAt(0).toLowerCase() + listName.slice(1) // camelCase
71
+
72
+ lines.push(` ${modelKey}: {`)
73
+
74
+ for (const [fieldName, fieldConfig] of fieldsWithExtensions) {
75
+ const isVirtual = fieldConfig.virtual
76
+
77
+ lines.push(` ${fieldName}: {`)
78
+
79
+ if (isVirtual) {
80
+ // Virtual fields don't need database fields - they compute from the full item
81
+ lines.push(` needs: {},`)
82
+ } else {
83
+ // Non-virtual fields need their database value
84
+ lines.push(` needs: { ${fieldName}: true },`)
85
+ }
86
+
87
+ lines.push(` compute: (${modelKey}) => {`)
88
+
89
+ if (!isVirtual) {
90
+ // For non-virtual fields, get the database value and check nullability
91
+ lines.push(` const value = ${modelKey}.${fieldName}`)
92
+ lines.push(` if (value === null || value === undefined) {`)
93
+ lines.push(` return undefined`)
94
+ lines.push(` }`)
95
+ }
96
+
97
+ lines.push(` // Call field's resolveOutput hook if available (synchronously)`)
98
+ lines.push(` if (!resolvedConfig) {`)
99
+ lines.push(
100
+ ` // Config not yet resolved - return undefined for virtual, value for regular`,
101
+ )
102
+ if (isVirtual) {
103
+ lines.push(` return undefined`)
104
+ } else {
105
+ lines.push(` return value`)
106
+ }
107
+ lines.push(` }`)
108
+ lines.push(
109
+ ` const fieldConfig = resolvedConfig.lists['${listName}'].fields['${fieldName}']`,
110
+ )
111
+ lines.push(` if (fieldConfig.hooks?.resolveOutput) {`)
112
+ lines.push(` return fieldConfig.hooks.resolveOutput({`)
113
+ lines.push(` operation: 'query',`)
114
+ if (isVirtual) {
115
+ lines.push(` value: undefined, // Virtual fields have no stored value`)
116
+ } else {
117
+ lines.push(` value,`)
118
+ }
119
+ lines.push(` item: ${modelKey},`)
120
+ lines.push(` listKey: '${listName}',`)
121
+ lines.push(` fieldName: '${fieldName}',`)
122
+ lines.push(
123
+ ` context: null as any, // Extension context doesn't have full context`,
124
+ )
125
+ lines.push(` })`)
126
+ lines.push(` }`)
127
+ if (isVirtual) {
128
+ lines.push(` return undefined`)
129
+ } else {
130
+ lines.push(` return value`)
131
+ }
132
+ lines.push(` },`)
133
+ lines.push(` },`)
134
+ }
135
+
136
+ lines.push(` },`)
137
+ }
138
+
139
+ lines.push(' },')
140
+ lines.push('})')
141
+ lines.push('')
142
+
143
+ return lines.join('\n')
144
+ }
145
+
146
+ /**
147
+ * Write Prisma extensions configuration to file
148
+ */
149
+ export function writePrismaExtensions(config: OpenSaasConfig, outputPath: string): void {
150
+ const extensions = generatePrismaExtensions(config)
151
+
152
+ // Ensure directory exists
153
+ const dir = path.dirname(outputPath)
154
+ if (!fs.existsSync(dir)) {
155
+ fs.mkdirSync(dir, { recursive: true })
156
+ }
157
+
158
+ fs.writeFileSync(outputPath, extensions, 'utf-8')
159
+ }
@@ -90,6 +90,11 @@ export function generatePrismaSchema(config: OpenSaasConfig): string {
90
90
 
91
91
  // Add regular fields
92
92
  for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
93
+ // Skip virtual fields - they don't create database columns
94
+ if (fieldConfig.virtual) {
95
+ continue
96
+ }
97
+
93
98
  if (fieldConfig.type === 'relationship') {
94
99
  relationshipFields.push({
95
100
  name: fieldName,
@@ -41,24 +41,96 @@ function isFieldOptional(field: FieldConfig): boolean {
41
41
  }
42
42
 
43
43
  /**
44
- * Generate TypeScript interface for a model
44
+ * Generate virtual fields type - only contains virtual fields
45
+ * This is intersected with Prisma's GetPayload to add virtual fields to query results
45
46
  */
46
- function generateModelType(listName: string, fields: Record<string, FieldConfig>): string {
47
+ function generateVirtualFieldsType(listName: string, fields: Record<string, FieldConfig>): string {
47
48
  const lines: string[] = []
49
+ const virtualFields = Object.entries(fields).filter(([_, config]) => config.type === 'virtual')
48
50
 
49
- lines.push(`export type ${listName} = {`)
51
+ lines.push(`/**`)
52
+ lines.push(` * Virtual fields for ${listName} - computed fields not in database`)
53
+ lines.push(` * These are added to query results via resolveOutput hooks`)
54
+ lines.push(` */`)
55
+ lines.push(`export type ${listName}VirtualFields = {`)
56
+
57
+ for (const [fieldName, fieldConfig] of virtualFields) {
58
+ const tsType = mapFieldTypeToTypeScript(fieldConfig)
59
+ if (!tsType) continue
60
+
61
+ const optional = isFieldOptional(fieldConfig)
62
+ const nullability = optional ? ' | null' : ''
63
+ lines.push(` ${fieldName}: ${tsType}${nullability}`)
64
+ }
65
+
66
+ // If no virtual fields, make it an empty object
67
+ if (virtualFields.length === 0) {
68
+ lines.push(' // No virtual fields defined')
69
+ }
70
+
71
+ lines.push('}')
72
+
73
+ return lines.join('\n')
74
+ }
75
+
76
+ /**
77
+ * Generate transformed fields type - fields with resultExtension transformations
78
+ * This replaces Prisma's base types with transformed types (e.g., string -> HashedPassword)
79
+ */
80
+ function generateTransformedFieldsType(
81
+ listName: string,
82
+ fields: Record<string, FieldConfig>,
83
+ ): string {
84
+ const lines: string[] = []
85
+ const transformedFields = Object.entries(fields).filter(([_, config]) => config.resultExtension)
86
+
87
+ lines.push(`/**`)
88
+ lines.push(` * Transformed fields for ${listName} - fields with resultExtension transformations`)
89
+ lines.push(` * These override Prisma's base types with transformed types via result extensions`)
90
+ lines.push(` */`)
91
+ lines.push(`export type ${listName}TransformedFields = {`)
92
+
93
+ for (const [fieldName, fieldConfig] of transformedFields) {
94
+ if (fieldConfig.resultExtension) {
95
+ const optional = isFieldOptional(fieldConfig)
96
+ const nullability = optional ? ' | undefined' : ''
97
+ lines.push(` ${fieldName}: ${fieldConfig.resultExtension.outputType}${nullability}`)
98
+ }
99
+ }
100
+
101
+ // If no transformed fields, make it an empty object
102
+ if (transformedFields.length === 0) {
103
+ lines.push(' // No transformed fields defined')
104
+ }
105
+
106
+ lines.push('}')
107
+
108
+ return lines.join('\n')
109
+ }
110
+
111
+ /**
112
+ * Generate TypeScript Output type for a model (includes virtual fields)
113
+ * This is kept for backwards compatibility but CustomDB uses Prisma's GetPayload + VirtualFields
114
+ */
115
+ function generateModelOutputType(listName: string, fields: Record<string, FieldConfig>): string {
116
+ const lines: string[] = []
117
+
118
+ lines.push(`export type ${listName}Output = {`)
50
119
  lines.push(' id: string')
51
120
 
52
121
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
122
+ // Skip virtual fields - they're in VirtualFields type
123
+ if (fieldConfig.type === 'virtual') continue
124
+
53
125
  if (fieldConfig.type === 'relationship') {
54
126
  const relField = fieldConfig as RelationshipField
55
127
  const [targetList] = relField.ref.split('.')
56
128
 
57
129
  if (relField.many) {
58
- lines.push(` ${fieldName}: ${targetList}[]`)
130
+ lines.push(` ${fieldName}?: ${targetList}Output[]`) // Optional since only present with include
59
131
  } else {
60
132
  lines.push(` ${fieldName}Id: string | null`)
61
- lines.push(` ${fieldName}: ${targetList} | null`)
133
+ lines.push(` ${fieldName}?: ${targetList}Output | null`) // Optional since only present with include
62
134
  }
63
135
  } else {
64
136
  const tsType = mapFieldTypeToTypeScript(fieldConfig)
@@ -72,11 +144,18 @@ function generateModelType(listName: string, fields: Record<string, FieldConfig>
72
144
 
73
145
  lines.push(' createdAt: Date')
74
146
  lines.push(' updatedAt: Date')
75
- lines.push('}')
147
+ lines.push('} & ' + listName + 'VirtualFields') // Include virtual fields
76
148
 
77
149
  return lines.join('\n')
78
150
  }
79
151
 
152
+ /**
153
+ * Generate convenience type alias (List = ListOutput)
154
+ */
155
+ function generateModelTypeAlias(listName: string): string {
156
+ return `export type ${listName} = ${listName}Output`
157
+ }
158
+
80
159
  /**
81
160
  * Generate CreateInput type
82
161
  */
@@ -86,6 +165,12 @@ function generateCreateInputType(listName: string, fields: Record<string, FieldC
86
165
  lines.push(`export type ${listName}CreateInput = {`)
87
166
 
88
167
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
168
+ // Skip virtual fields - they don't accept input in create operations
169
+ // Virtual fields with resolveInput hooks handle side effects but don't store data
170
+ if (fieldConfig.virtual) {
171
+ continue
172
+ }
173
+
89
174
  if (fieldConfig.type === 'relationship') {
90
175
  const relField = fieldConfig as RelationshipField
91
176
 
@@ -118,6 +203,12 @@ function generateUpdateInputType(listName: string, fields: Record<string, FieldC
118
203
  lines.push(`export type ${listName}UpdateInput = {`)
119
204
 
120
205
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
206
+ // Virtual fields with resolveInput hooks can accept input for side effects
207
+ // but we still skip them in the input type since they don't store data
208
+ if (fieldConfig.virtual) {
209
+ continue
210
+ }
211
+
121
212
  if (fieldConfig.type === 'relationship') {
122
213
  const relField = fieldConfig as RelationshipField
123
214
 
@@ -219,20 +310,104 @@ function generateHookTypes(listName: string): string {
219
310
  }
220
311
 
221
312
  /**
222
- * Generate Context type with all operations
313
+ * Generate custom DB interface that uses Prisma's conditional types with virtual and transformed fields
314
+ * This leverages Prisma's GetPayload utility to get correct types based on select/include
315
+ */
316
+ function generateCustomDBType(config: OpenSaasConfig): string {
317
+ const lines: string[] = []
318
+
319
+ // Generate list of db keys to omit from AccessControlledDB
320
+ const dbKeys = Object.keys(config.lists).map((listName) => {
321
+ const dbKey = listName.charAt(0).toLowerCase() + listName.slice(1)
322
+ return `'${dbKey}'`
323
+ })
324
+
325
+ lines.push('/**')
326
+ lines.push(
327
+ " * Custom DB type that uses Prisma's conditional types with virtual and transformed field support",
328
+ )
329
+ lines.push(
330
+ ' * Types change based on select/include - relationships only present when explicitly included',
331
+ )
332
+ lines.push(' * Virtual fields and transformed fields are added to the base model type')
333
+ lines.push(' */')
334
+ lines.push('export type CustomDB = Omit<AccessControlledDB<PrismaClient>, ')
335
+ lines.push(` ${dbKeys.join(' | ')}`)
336
+ lines.push('> & {')
337
+
338
+ // For each list, create strongly-typed methods using Prisma's conditional types
339
+ for (const listName of Object.keys(config.lists)) {
340
+ const dbKey = listName.charAt(0).toLowerCase() + listName.slice(1) // camelCase
341
+
342
+ lines.push(` ${dbKey}: {`)
343
+
344
+ // findUnique - generic to preserve Prisma's conditional return type
345
+ lines.push(` findUnique: <T extends Prisma.${listName}FindUniqueArgs>(`)
346
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}FindUniqueArgs>`)
347
+ lines.push(
348
+ ` ) => Promise<(Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields) | null>`,
349
+ )
350
+
351
+ // findMany - generic to preserve Prisma's conditional return type
352
+ lines.push(` findMany: <T extends Prisma.${listName}FindManyArgs>(`)
353
+ lines.push(` args?: Prisma.SelectSubset<T, Prisma.${listName}FindManyArgs>`)
354
+ lines.push(
355
+ ` ) => Promise<Array<Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields>>`,
356
+ )
357
+
358
+ // create - generic to preserve Prisma's conditional return type
359
+ lines.push(` create: <T extends Prisma.${listName}CreateArgs>(`)
360
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}CreateArgs>`)
361
+ lines.push(
362
+ ` ) => Promise<Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields>`,
363
+ )
364
+
365
+ // update - generic to preserve Prisma's conditional return type
366
+ lines.push(` update: <T extends Prisma.${listName}UpdateArgs>(`)
367
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}UpdateArgs>`)
368
+ lines.push(
369
+ ` ) => Promise<(Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields) | null>`,
370
+ )
371
+
372
+ // delete - generic to preserve Prisma's conditional return type
373
+ lines.push(` delete: <T extends Prisma.${listName}DeleteArgs>(`)
374
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}DeleteArgs>`)
375
+ lines.push(
376
+ ` ) => Promise<(Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields) | null>`,
377
+ )
378
+
379
+ // count - no changes to return type
380
+ lines.push(` count: (args?: Prisma.${listName}CountArgs) => Promise<number>`)
381
+
382
+ lines.push(` }`)
383
+ }
384
+
385
+ lines.push('}')
386
+
387
+ return lines.join('\n')
388
+ }
389
+
390
+ /**
391
+ * Generate Context type that is compatible with AccessContext
223
392
  */
224
- function generateContextType(): string {
393
+ function generateContextType(_config: OpenSaasConfig): string {
225
394
  const lines: string[] = []
226
395
 
227
- lines.push('export type Context<TSession extends OpensaasSession = OpensaasSession> = {')
228
- lines.push(' db: AccessControlledDB<PrismaClient>')
396
+ lines.push('/**')
397
+ lines.push(
398
+ ' * Context type compatible with AccessContext but with CustomDB for virtual field typing',
399
+ )
400
+ lines.push(
401
+ ' * Extends AccessContext and overrides db property to include virtual fields in output types',
402
+ )
403
+ lines.push(' */')
404
+ lines.push(
405
+ "export type Context<TSession extends OpensaasSession = OpensaasSession> = Omit<AccessContext<PrismaClient>, 'db' | 'session'> & {",
406
+ )
407
+ lines.push(' db: CustomDB')
229
408
  lines.push(' session: TSession')
230
- lines.push(' prisma: PrismaClient')
231
- lines.push(' storage: StorageUtils')
232
- lines.push(' plugins: PluginServices')
233
409
  lines.push(' serverAction: (props: ServerActionProps) => Promise<unknown>')
234
410
  lines.push(' sudo: () => Context<TSession>')
235
- lines.push(' _isSudo: boolean')
236
411
  lines.push('}')
237
412
 
238
413
  return lines.join('\n')
@@ -299,7 +474,7 @@ export function generateTypes(config: OpenSaasConfig): string {
299
474
  // Add necessary imports
300
475
  // Use alias for Session to avoid conflicts if user has a list named "Session"
301
476
  lines.push(
302
- "import type { Session as OpensaasSession, StorageUtils, ServerActionProps, AccessControlledDB } from '@opensaas/stack-core'",
477
+ "import type { Session as OpensaasSession, StorageUtils, ServerActionProps, AccessControlledDB, AccessContext } from '@opensaas/stack-core'",
303
478
  )
304
479
  lines.push("import type { PrismaClient, Prisma } from './prisma-client/client'")
305
480
  lines.push("import type { PluginServices } from './plugin-types'")
@@ -316,7 +491,15 @@ export function generateTypes(config: OpenSaasConfig): string {
316
491
 
317
492
  // Generate types for each list
318
493
  for (const [listName, listConfig] of Object.entries(config.lists)) {
319
- lines.push(generateModelType(listName, listConfig.fields))
494
+ // Generate VirtualFields type first (needed by Output type and CustomDB)
495
+ lines.push(generateVirtualFieldsType(listName, listConfig.fields))
496
+ lines.push('')
497
+ // Generate TransformedFields type (needed by CustomDB)
498
+ lines.push(generateTransformedFieldsType(listName, listConfig.fields))
499
+ lines.push('')
500
+ lines.push(generateModelOutputType(listName, listConfig.fields))
501
+ lines.push('')
502
+ lines.push(generateModelTypeAlias(listName))
320
503
  lines.push('')
321
504
  lines.push(generateCreateInputType(listName, listConfig.fields))
322
505
  lines.push('')
@@ -328,8 +511,12 @@ export function generateTypes(config: OpenSaasConfig): string {
328
511
  lines.push('')
329
512
  }
330
513
 
514
+ // Generate CustomDB interface
515
+ lines.push(generateCustomDBType(config))
516
+ lines.push('')
517
+
331
518
  // Generate Context type
332
- lines.push(generateContextType())
519
+ lines.push(generateContextType(config))
333
520
 
334
521
  return lines.join('\n')
335
522
  }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { generateCommand } from './commands/generate.js'
5
5
  import { initCommand } from './commands/init.js'
6
6
  import { devCommand } from './commands/dev.js'
7
7
  import { createMCPCommand } from './commands/mcp.js'
8
+ import { createMigrateCommand } from './commands/migrate.js'
8
9
 
9
10
  const program = new Command()
10
11
 
@@ -39,4 +40,7 @@ program
39
40
  // Add MCP command group
40
41
  program.addCommand(createMCPCommand())
41
42
 
43
+ // Add migrate command
44
+ program.addCommand(createMigrateCommand())
45
+
42
46
  program.parse()