@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
|
@@ -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(
|
|
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
|
})
|
package/src/generator/context.ts
CHANGED
|
@@ -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:
|
|
134
|
-
let prisma:
|
|
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
|
-
|
|
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
|
/**
|
package/src/generator/index.ts
CHANGED
|
@@ -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'
|
package/src/generator/lists.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/generator/prisma.ts
CHANGED
|
@@ -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,
|
package/src/generator/types.ts
CHANGED
|
@@ -41,24 +41,96 @@ function isFieldOptional(field: FieldConfig): boolean {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Generate
|
|
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
|
|
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(
|
|
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}
|
|
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}
|
|
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
|
|
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('
|
|
228
|
-
lines.push(
|
|
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
|
-
|
|
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()
|