From c752084eb30d5f1e333817170f7d420826143657 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 00:47:08 +0000 Subject: [PATCH] Simplify add command to support migrations instead of schema files Replace the complex SQL merge/parse/placement logic with a simple migration component type. Templates now use "migration" components that copy SQL files to the migrations directory with a timestamp prefix. This removes ~1000 lines of SQL AST parsing, statement merging, and schema placement code. https://claude.ai/code/session_0161GS2RCbLFW2XqrwtjRYfp --- internal/add/add.go | 849 ++------------------------------------- internal/add/add_test.go | 289 ++----------- internal/add/config.go | 49 --- internal/add/types.go | 1 - 4 files changed, 70 insertions(+), 1118 deletions(-) diff --git a/internal/add/add.go b/internal/add/add.go index 4e1dbaf5c..5a87780d9 100644 --- a/internal/add/add.go +++ b/internal/add/add.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/url" "os" "path" @@ -12,25 +11,22 @@ import ( "sort" "strconv" "strings" + "time" "github.com/go-errors/errors" "github.com/joho/godotenv" - mg "github.com/multigres/multigres/go/parser" - "github.com/multigres/multigres/go/parser/ast" "github.com/spf13/afero" "github.com/spf13/viper" - "github.com/supabase/cli/internal/component/placement" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/credentials" "github.com/supabase/cli/internal/utils/flags" - sqlparser "github.com/supabase/cli/pkg/parser" ) type runtimeState struct { contextValues map[string]string refs map[string]string config *configEditor - addedSql []string + migrationSeq int } func Run(ctx context.Context, source string, inputArgs []string, fsys afero.Fs) error { @@ -78,9 +74,6 @@ func Run(ctx context.Context, source string, inputArgs []string, fsys afero.Fs) } } } - if len(state.addedSql) > 0 && len(utils.Config.Db.Migrations.SchemaPaths) == 0 { - state.config.ensureDefaultSchemaPaths() - } if err := state.config.save(fsys); err != nil { return err } @@ -261,31 +254,38 @@ func executeComponent(ctx context.Context, src *templateSource, c TemplateCompon if len(componentType) == 0 { return errors.New("template component requires type") } - switch { - case isSchemaComponentType(componentType): - return executeSQLComponent(src, c, componentType, fsys, state) - case componentType == "edge_function": + switch componentType { + case "migration": + return executeMigrationComponent(src, c, fsys, state) + case "edge_function": return executeEdgeFunctionComponent(src, c, fsys, state) - case componentType == "secret": + case "secret": return executeSecretComponent(c, fsys, state) - case componentType == "vault": + case "vault": return executeVaultComponent(c, state) default: - // Unknown component types fall back to SQL handling and default placement. - return executeSQLComponent(src, c, componentType, fsys, state) + return errors.Errorf("unsupported component type: %s", componentType) } } -func executeSQLComponent(src *templateSource, c TemplateComponent, componentType string, fsys afero.Fs, state *runtimeState) error { +// migrationTimestamp returns a timestamp string for migration file naming. +// Each call increments the sequence counter to ensure unique timestamps +// when multiple migrations are added in the same operation. +var migrationTimestamp = func(seq int) string { + t := time.Now().UTC().Add(time.Duration(seq) * time.Second) + return t.Format("20060102150405") +} + +func executeMigrationComponent(src *templateSource, c TemplateComponent, fsys afero.Fs, state *runtimeState) error { templatePaths, err := renderComponentPaths(c.Path, state.contextValues, state.refs) if err != nil { return err } if len(templatePaths) == 0 { - return errors.Errorf("%s component requires path", componentType) + return errors.New("migration component requires path") } if len(templatePaths) > 1 { - return errors.Errorf("%s component expects a single path, found %d", componentType, len(templatePaths)) + return errors.Errorf("migration component expects a single path, found %d", len(templatePaths)) } templatePath := templatePaths[0] sqlData, err := src.readTemplatePath(templatePath, true) @@ -305,31 +305,18 @@ func executeSQLComponent(src *templateSource, c TemplateComponent, componentType return err } if len(name) == 0 { - return errors.Errorf("unable to resolve component name for %s", componentType) - } - schema := c.Schema - if len(strings.TrimSpace(schema)) == 0 { - schema = "public" + return errors.New("migration component requires a name") } - schema, err = renderValue(schema, state.contextValues, state.refs) - if err != nil { + timestamp := migrationTimestamp(state.migrationSeq) + state.migrationSeq++ + filename := fmt.Sprintf("%s_%s.sql", timestamp, name) + destPath := filepath.Join(utils.MigrationsDir, filename) + if err := utils.MkdirIfNotExistFS(fsys, utils.MigrationsDir); err != nil { return err } - defaultPath := defaultSQLPath(componentType, schema, name) - destPath := placement.ResolvePath(componentType, utils.Config.Db.Migrations.SchemaPlacement, placement.Context{ - Schema: schema, - Name: name, - DefaultPath: defaultPath, - }) - if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(destPath)); err != nil { - return err - } - changed, err := mergeSQLFile(destPath, sqlContent, fsys) - if err != nil { - return err - } - if changed { - state.addedSql = append(state.addedSql, destPath) + content := strings.TrimSpace(sqlContent) + "\n" + if err := afero.WriteFile(fsys, destPath, []byte(content), 0644); err != nil { + return errors.Errorf("failed to write migration file: %w", err) } setComponentRefs(name, map[string]string{ "path": destPath, @@ -407,751 +394,6 @@ func executeEdgeFunctionComponent(src *templateSource, c TemplateComponent, fsys return applyOutputs(c, state) } -func mergeSQLFile(destPath, incomingSQL string, fsys afero.Fs) (bool, error) { - block := strings.TrimSpace(incomingSQL) - if len(block) == 0 { - return false, nil - } - existing, err := afero.ReadFile(fsys, destPath) - if errors.Is(err, os.ErrNotExist) { - content := block + "\n" - if err := afero.WriteFile(fsys, destPath, []byte(content), 0644); err != nil { - return false, errors.Errorf("failed to write SQL component: %w", err) - } - return true, nil - } else if err != nil { - return false, errors.Errorf("failed to read SQL component for merge: %w", err) - } - existingText := string(existing) - mergedText, changed, structured := mergeSQLStatements(existingText, block) - if structured { - if !changed { - return false, nil - } - if err := afero.WriteFile(fsys, destPath, []byte(mergedText), 0644); err != nil { - return false, errors.Errorf("failed to write SQL component: %w", err) - } - return true, nil - } - if strings.Contains(existingText, block) { - return false, nil - } - mergedTextFallback := strings.TrimRight(existingText, "\n") - if len(strings.TrimSpace(mergedTextFallback)) > 0 { - mergedTextFallback += "\n\n" - } - mergedTextFallback += block + "\n" - if err := afero.WriteFile(fsys, destPath, []byte(mergedTextFallback), 0644); err != nil { - return false, errors.Errorf("failed to write SQL component: %w", err) - } - return true, nil -} - -type parsedSQLStatement struct { - raw string - stmt ast.Stmt - parsed bool - modified bool -} - -type createStmtRef struct { - index int - stmt *ast.CreateStmt -} - -func mergeSQLStatements(existingText, incomingText string) (string, bool, bool) { - existingStatements, err := splitSQLStatements(strings.NewReader(existingText)) - if err != nil { - return "", false, false - } - incomingStatements, err := splitSQLStatements(strings.NewReader(incomingText)) - if err != nil { - return "", false, false - } - entries := make([]parsedSQLStatement, 0, len(existingStatements)) - creates := make([]createStmtRef, 0, len(existingStatements)) - seen := map[string]struct{}{} - for _, raw := range existingStatements { - entry := parseSQLStatement(raw) - entries = append(entries, entry) - seen[statementKey(entry)] = struct{}{} - if createStmt, ok := entry.stmt.(*ast.CreateStmt); ok && createStmt != nil { - creates = append(creates, createStmtRef{ - index: len(entries) - 1, - stmt: createStmt, - }) - } - } - changed := false - for _, raw := range incomingStatements { - incoming := parseSQLStatement(raw) - if _, found := seen[statementKey(incoming)]; found { - continue - } - if alterStmt, ok := incoming.stmt.(*ast.AlterTableStmt); ok && alterStmt != nil { - if target := findCreateStmtForAlter(alterStmt, creates); target != nil { - handled, createChanged := applyAlterTableStmt(target.stmt, alterStmt) - if handled { - if createChanged { - entries[target.index].modified = true - seen[statementKey(entries[target.index])] = struct{}{} - changed = true - } - // Skip appending the ALTER statement if it is already represented by CREATE TABLE. - continue - } - } - } - entries = append(entries, incoming) - seen[statementKey(incoming)] = struct{}{} - changed = true - } - if !changed { - return "", false, true - } - serialized := serializeSQLStatements(entries) - return serialized, true, true -} - -func splitSQLStatements(r io.Reader) ([]string, error) { - return sqlparser.Split(r, strings.TrimSpace) -} - -func parseSQLStatement(raw string) parsedSQLStatement { - parsed, err := mg.ParseSQL(raw) - if err != nil || len(parsed) != 1 { - return parsedSQLStatement{raw: raw} - } - return parsedSQLStatement{ - raw: raw, - stmt: parsed[0], - parsed: true, - } -} - -func statementKey(stmt parsedSQLStatement) string { - if stmt.parsed { - if sql, ok := safeStmtSQL(stmt.stmt); ok { - return canonicalSQL(sql) - } - } - return canonicalSQL(stmt.raw) -} - -func safeStmtSQL(stmt ast.Stmt) (string, bool) { - if stmt == nil { - return "", false - } - defer func() { - _ = recover() - }() - return stmt.SqlString(), true -} - -func serializeSQLStatements(entries []parsedSQLStatement) string { - lines := make([]string, 0, len(entries)) - for _, entry := range entries { - text := strings.TrimSpace(entry.raw) - if entry.modified && entry.parsed { - if sql, ok := safeStmtSQL(entry.stmt); ok { - text = strings.TrimSpace(sql) - } - } - text = strings.TrimSpace(strings.TrimSuffix(text, ";")) - if len(text) == 0 { - continue - } - lines = append(lines, text+";") - } - if len(lines) == 0 { - return "" - } - return strings.Join(lines, "\n") + "\n" -} - -func canonicalSQL(sql string) string { - trimmed := strings.TrimSpace(strings.TrimSuffix(sql, ";")) - if len(trimmed) == 0 { - return "" - } - return strings.Join(strings.Fields(strings.ToLower(trimmed)), " ") -} - -func findCreateStmtForAlter(alter *ast.AlterTableStmt, creates []createStmtRef) *createStmtRef { - if alter == nil || alter.Relation == nil || len(creates) == 0 { - return nil - } - alterSchema := normalizeIdentifier(alter.Relation.SchemaName) - alterName := normalizeIdentifier(alter.Relation.RelName) - if len(alterName) == 0 { - return nil - } - byName := make([]*createStmtRef, 0, 1) - for i := range creates { - c := &creates[i] - if c.stmt == nil || c.stmt.Relation == nil { - continue - } - createSchema := normalizeIdentifier(c.stmt.Relation.SchemaName) - createName := normalizeIdentifier(c.stmt.Relation.RelName) - if createName != alterName { - continue - } - if len(alterSchema) > 0 && createSchema == alterSchema { - return c - } - byName = append(byName, c) - } - if len(byName) == 1 { - return byName[0] - } - return nil -} - -func normalizeIdentifier(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func applyAlterTableStmt(create *ast.CreateStmt, alter *ast.AlterTableStmt) (bool, bool) { - if create == nil || alter == nil || alter.Cmds == nil || alter.Cmds.Len() == 0 { - return false, false - } - clone, ok := cloneCreateStmt(create) - if !ok { - return false, false - } - changed := false - for _, item := range alter.Cmds.Items { - cmd, ok := item.(*ast.AlterTableCmd) - if !ok || cmd == nil { - return false, false - } - applied, cmdChanged := applyAlterTableCmd(clone, cmd) - if !applied { - return false, false - } - changed = changed || cmdChanged - } - if changed { - *create = *clone - } - return true, changed -} - -func cloneCreateStmt(create *ast.CreateStmt) (*ast.CreateStmt, bool) { - sql, ok := safeStmtSQL(create) - if !ok { - return nil, false - } - parsed, err := mg.ParseSQL(sql) - if err != nil || len(parsed) != 1 { - return nil, false - } - cloned, ok := parsed[0].(*ast.CreateStmt) - if !ok { - return nil, false - } - return cloned, true -} - -func applyAlterTableCmd(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - switch cmd.Subtype { - case ast.AT_AddColumn: - return applyAddColumn(create, cmd) - case ast.AT_DropColumn: - return applyDropColumn(create, cmd) - case ast.AT_ColumnDefault: - return applyColumnDefault(create, cmd) - case ast.AT_SetNotNull: - return applySetNotNull(create, cmd) - case ast.AT_DropNotNull: - return applyDropNotNull(create, cmd) - case ast.AT_AlterColumnType: - return applyAlterColumnType(create, cmd) - case ast.AT_AddConstraint: - return applyAddConstraint(create, cmd) - case ast.AT_DropConstraint: - return applyDropConstraint(create, cmd) - case ast.AT_AddIdentity: - return applyAddIdentity(create, cmd) - case ast.AT_SetIdentity: - return applySetIdentity(create, cmd) - case ast.AT_DropIdentity: - return applyDropIdentity(create, cmd) - default: - return false, false - } -} - -func applyAddColumn(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col, ok := cmd.Def.(*ast.ColumnDef) - if !ok || col == nil || len(strings.TrimSpace(col.Colname)) == 0 { - return false, false - } - if existing := findColumnDef(create, col.Colname); existing != nil { - if cmd.MissingOk || sameNodeSQL(existing, col) { - return true, false - } - return false, false - } - if create.TableElts == nil { - create.TableElts = ast.NewNodeList() - } - create.TableElts.Append(col) - return true, true -} - -func applyDropColumn(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - if len(strings.TrimSpace(cmd.Name)) == 0 { - return false, false - } - if create.TableElts == nil || create.TableElts.Len() == 0 { - return cmd.MissingOk, false - } - changed := false - filtered := make([]ast.Node, 0, len(create.TableElts.Items)) - for _, item := range create.TableElts.Items { - if col, ok := item.(*ast.ColumnDef); ok && identifierEquals(col.Colname, cmd.Name) { - changed = true - continue - } - filtered = append(filtered, item) - } - if !changed { - return cmd.MissingOk, false - } - create.TableElts.Items = filtered - return true, true -} - -func applyColumnDefault(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - if cmd.Def == nil { - changed := clearColumnDefault(col) - return true, changed - } - if defaultNodeSQL(col) == canonicalNodeSQL(cmd.Def) { - return true, false - } - clearColumnDefault(col) - defaultConstraint := ast.NewConstraint(ast.CONSTR_DEFAULT) - defaultConstraint.RawExpr = cmd.Def - ensureColumnConstraints(col).Append(defaultConstraint) - return true, true -} - -func applySetNotNull(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - if columnHasConstraintType(col, ast.CONSTR_NOTNULL) || col.IsNotNull { - return true, false - } - constraint := ast.NewConstraint(ast.CONSTR_NOTNULL) - ensureColumnConstraints(col).Append(constraint) - col.IsNotNull = false - return true, true -} - -func applyDropNotNull(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - changed := removeColumnConstraints(col, func(c *ast.Constraint) bool { - return c.Contype == ast.CONSTR_NOTNULL - }) - if col.IsNotNull { - col.IsNotNull = false - changed = true - } - return true, changed -} - -func applyAlterColumnType(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - def, ok := cmd.Def.(*ast.ColumnDef) - if !ok || def == nil || def.TypeName == nil { - return false, false - } - // TYPE ... USING cannot be represented directly in CREATE TABLE. - if def.RawDefault != nil { - return false, false - } - if sameNodeSQL(col.TypeName, def.TypeName) { - return true, false - } - col.TypeName = def.TypeName - return true, true -} - -func applyAddConstraint(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - constraint, ok := cmd.Def.(*ast.Constraint) - if !ok || constraint == nil { - return false, false - } - existing, found := findConstraint(create, constraint.Conname, constraint) - if found { - if sameNodeSQL(existing, constraint) || samePrimaryKeyConstraint(create, existing, constraint) { - return true, false - } - return false, false - } - if create.TableElts == nil { - create.TableElts = ast.NewNodeList() - } - create.TableElts.Append(constraint) - return true, true -} - -func applyDropConstraint(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - if len(strings.TrimSpace(cmd.Name)) == 0 { - return false, false - } - changed := false - if create.TableElts != nil { - filtered := make([]ast.Node, 0, len(create.TableElts.Items)) - for _, item := range create.TableElts.Items { - if constraint, ok := item.(*ast.Constraint); ok && identifierEquals(constraint.Conname, cmd.Name) { - changed = true - continue - } - filtered = append(filtered, item) - } - create.TableElts.Items = filtered - } - if len(create.Constraints) > 0 { - filtered := make([]*ast.Constraint, 0, len(create.Constraints)) - for _, constraint := range create.Constraints { - if constraint != nil && identifierEquals(constraint.Conname, cmd.Name) { - changed = true - continue - } - filtered = append(filtered, constraint) - } - create.Constraints = filtered - } - for _, item := range createTableElements(create) { - col, ok := item.(*ast.ColumnDef) - if !ok || col == nil { - continue - } - if removeColumnConstraints(col, func(c *ast.Constraint) bool { - return identifierEquals(c.Conname, cmd.Name) - }) { - changed = true - } - } - if !changed { - return cmd.MissingOk, false - } - return true, true -} - -func applyAddIdentity(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - constraint, ok := cmd.Def.(*ast.Constraint) - if !ok || constraint == nil || constraint.Contype != ast.CONSTR_IDENTITY { - return false, false - } - if existing := findColumnConstraint(col, func(c *ast.Constraint) bool { - return c.Contype == ast.CONSTR_IDENTITY - }); existing != nil { - if sameNodeSQL(existing, constraint) { - return true, false - } - return false, false - } - ensureColumnConstraints(col).Append(constraint) - return true, true -} - -func applySetIdentity(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - identity := findColumnConstraint(col, func(c *ast.Constraint) bool { - return c.Contype == ast.CONSTR_IDENTITY - }) - if identity == nil { - identity = ast.NewConstraint(ast.CONSTR_IDENTITY) - identity.GeneratedWhen = ast.ATTRIBUTE_IDENTITY_BY_DEFAULT - ensureColumnConstraints(col).Append(identity) - } - before := canonicalNodeSQL(identity) - switch v := cmd.Def.(type) { - case *ast.Constraint: - if v.Contype != ast.CONSTR_IDENTITY { - return false, false - } - identity.GeneratedWhen = v.GeneratedWhen - identity.Options = v.Options - case *ast.NodeList: - for _, item := range v.Items { - def, ok := item.(*ast.DefElem) - if !ok || def == nil { - continue - } - if def.Defname == "generated" { - if integer, ok := def.Arg.(*ast.Integer); ok { - switch integer.IVal { - case int(97): // 'a' => ALWAYS - identity.GeneratedWhen = ast.ATTRIBUTE_IDENTITY_ALWAYS - case int(100): // 'd' => BY DEFAULT - identity.GeneratedWhen = ast.ATTRIBUTE_IDENTITY_BY_DEFAULT - } - } - } - } - identity.Options = v - default: - return false, false - } - after := canonicalNodeSQL(identity) - return true, before != after -} - -func applyDropIdentity(create *ast.CreateStmt, cmd *ast.AlterTableCmd) (bool, bool) { - col := findColumnDef(create, cmd.Name) - if col == nil { - return false, false - } - changed := removeColumnConstraints(col, func(c *ast.Constraint) bool { - return c.Contype == ast.CONSTR_IDENTITY - }) - return true, changed -} - -func findColumnDef(create *ast.CreateStmt, name string) *ast.ColumnDef { - for _, item := range createTableElements(create) { - col, ok := item.(*ast.ColumnDef) - if ok && col != nil && identifierEquals(col.Colname, name) { - return col - } - } - return nil -} - -func createTableElements(create *ast.CreateStmt) []ast.Node { - if create == nil || create.TableElts == nil { - return nil - } - return create.TableElts.Items -} - -func identifierEquals(a, b string) bool { - return normalizeIdentifier(a) == normalizeIdentifier(b) -} - -func ensureColumnConstraints(col *ast.ColumnDef) *ast.NodeList { - if col.Constraints == nil { - col.Constraints = ast.NewNodeList() - } - return col.Constraints -} - -func findColumnConstraint(col *ast.ColumnDef, match func(*ast.Constraint) bool) *ast.Constraint { - if col == nil || col.Constraints == nil { - return nil - } - for _, item := range col.Constraints.Items { - constraint, ok := item.(*ast.Constraint) - if ok && constraint != nil && match(constraint) { - return constraint - } - } - return nil -} - -func columnHasConstraintType(col *ast.ColumnDef, kind ast.ConstrType) bool { - return findColumnConstraint(col, func(c *ast.Constraint) bool { - return c.Contype == kind - }) != nil -} - -func removeColumnConstraints(col *ast.ColumnDef, match func(*ast.Constraint) bool) bool { - if col == nil || col.Constraints == nil || col.Constraints.Len() == 0 { - return false - } - changed := false - filtered := make([]ast.Node, 0, len(col.Constraints.Items)) - for _, item := range col.Constraints.Items { - constraint, ok := item.(*ast.Constraint) - if ok && constraint != nil && match(constraint) { - changed = true - continue - } - filtered = append(filtered, item) - } - if changed { - col.Constraints.Items = filtered - } - return changed -} - -func clearColumnDefault(col *ast.ColumnDef) bool { - changed := false - if col.RawDefault != nil { - col.RawDefault = nil - changed = true - } - if removeColumnConstraints(col, func(c *ast.Constraint) bool { - return c.Contype == ast.CONSTR_DEFAULT - }) { - changed = true - } - return changed -} - -func defaultNodeSQL(col *ast.ColumnDef) string { - if col == nil { - return "" - } - if col.RawDefault != nil { - return canonicalNodeSQL(col.RawDefault) - } - if constraint := findColumnConstraint(col, func(c *ast.Constraint) bool { - return c.Contype == ast.CONSTR_DEFAULT - }); constraint != nil && constraint.RawExpr != nil { - return canonicalNodeSQL(constraint.RawExpr) - } - return "" -} - -func canonicalNodeSQL(node ast.Node) string { - if node == nil { - return "" - } - defer func() { - _ = recover() - }() - return canonicalSQL(node.SqlString()) -} - -func sameNodeSQL(a, b ast.Node) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return canonicalNodeSQL(a) == canonicalNodeSQL(b) -} - -func findConstraint(create *ast.CreateStmt, conname string, exemplar *ast.Constraint) (*ast.Constraint, bool) { - if len(conname) > 0 { - for _, constraint := range listTableConstraints(create) { - if constraint != nil && identifierEquals(constraint.Conname, conname) { - return constraint, true - } - } - for _, item := range createTableElements(create) { - col, ok := item.(*ast.ColumnDef) - if !ok || col == nil { - continue - } - if constraint := findColumnConstraint(col, func(c *ast.Constraint) bool { - return identifierEquals(c.Conname, conname) - }); constraint != nil { - return constraint, true - } - } - } - if exemplar == nil { - return nil, false - } - for _, constraint := range listTableConstraints(create) { - if constraint != nil && sameNodeSQL(constraint, exemplar) { - return constraint, true - } - } - return nil, false -} - -func listTableConstraints(create *ast.CreateStmt) []*ast.Constraint { - result := make([]*ast.Constraint, 0, len(create.Constraints)) - for _, item := range createTableElements(create) { - if constraint, ok := item.(*ast.Constraint); ok && constraint != nil { - result = append(result, constraint) - } - } - result = append(result, create.Constraints...) - return result -} - -func samePrimaryKeyConstraint(create *ast.CreateStmt, existing, incoming *ast.Constraint) bool { - if existing == nil || incoming == nil { - return false - } - if existing.Contype != ast.CONSTR_PRIMARY || incoming.Contype != ast.CONSTR_PRIMARY { - return false - } - incomingCols := primaryKeyColumns(incoming, "") - if len(incomingCols) == 0 { - return false - } - existingCols := primaryKeyColumns(existing, "") - if len(existingCols) == 0 { - existingCols = existingPrimaryColumns(create) - } - if len(existingCols) != len(incomingCols) { - return false - } - for i := range existingCols { - if !identifierEquals(existingCols[i], incomingCols[i]) { - return false - } - } - return true -} - -func existingPrimaryColumns(create *ast.CreateStmt) []string { - for _, constraint := range listTableConstraints(create) { - if constraint != nil && constraint.Contype == ast.CONSTR_PRIMARY { - if cols := primaryKeyColumns(constraint, ""); len(cols) > 0 { - return cols - } - } - } - for _, item := range createTableElements(create) { - col, ok := item.(*ast.ColumnDef) - if !ok || col == nil { - continue - } - if columnHasConstraintType(col, ast.CONSTR_PRIMARY) { - return []string{col.Colname} - } - } - return nil -} - -func primaryKeyColumns(constraint *ast.Constraint, fallback string) []string { - if constraint == nil { - return nil - } - if constraint.Keys != nil && constraint.Keys.Len() > 0 { - cols := make([]string, 0, constraint.Keys.Len()) - for _, item := range constraint.Keys.Items { - if s, ok := item.(*ast.String); ok { - cols = append(cols, s.SVal) - } - } - return cols - } - if len(fallback) > 0 { - return []string{fallback} - } - return nil -} - func executeSecretComponent(c TemplateComponent, fsys afero.Fs, state *runtimeState) error { key, err := renderValue(c.Key, state.contextValues, state.refs) if err != nil { @@ -1240,39 +482,6 @@ func setComponentRefs(componentName string, values map[string]string, refs map[s } } -func defaultSQLPath(componentType, schema, name string) string { - switch componentType { - case "types": - return filepath.Join(utils.SchemasDir, "types.sql") - case "tables": - return filepath.Join(utils.SchemasDir, "tables", name+".sql") - case "functions": - return filepath.Join(utils.SchemasDir, "functions", name+".sql") - case "triggers": - return filepath.Join(utils.SchemasDir, "triggers", name+".sql") - case "policies": - return filepath.Join(utils.SchemasDir, "policies", name+".sql") - case "extensions": - return filepath.Join(utils.SchemasDir, "extensions.sql") - case "schemas": - return filepath.Join(utils.SchemasDir, schema, "schema.sql") - default: - return filepath.Join(utils.SchemasDir, name+".sql") - } -} - -func isSchemaComponentType(componentType string) bool { - switch componentType { - case "schemas", "types", "sequences", "tables", "foreign_tables", "functions", "triggers", "procedures", - "materialized_views", "views", "policies", "domains", "operators", "roles", "extensions", - "foreign_data_wrappers", "publications", "subscriptions", "event_triggers", "tablespaces", - "variables", "unqualified": - return true - default: - return false - } -} - func renderComponentPaths(rawPaths TemplatePath, context map[string]string, refs map[string]string) ([]string, error) { paths := make([]string, 0, len(rawPaths)) for _, raw := range rawPaths { diff --git a/internal/add/add_test.go b/internal/add/add_test.go index 05b659a5e..cea996bab 100644 --- a/internal/add/add_test.go +++ b/internal/add/add_test.go @@ -11,7 +11,6 @@ import ( "github.com/joho/godotenv" "github.com/spf13/afero" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/utils" @@ -52,6 +51,15 @@ func TestAddRunWithLocalTemplate(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteConfig(fsys, false)) + // Override timestamp to produce deterministic filenames. + origTimestamp := migrationTimestamp + seq := 0 + migrationTimestamp = func(_ int) string { + seq++ + return "20260305120000" + } + t.Cleanup(func() { migrationTimestamp = origTimestamp }) + template := `{ "name": "add-embeddings", "inputs": { @@ -63,7 +71,7 @@ func TestAddRunWithLocalTemplate(t *testing.T) { { "name": "provision_database", "components": [ - {"name": "embedding-column", "type": "tables", "path": "./sql/add-embedding-column.sql"} + {"name": "add-embedding-column", "type": "migration", "path": "./sql/add-embedding-column.sql"} ] }, { @@ -91,11 +99,13 @@ func TestAddRunWithLocalTemplate(t *testing.T) { "embedding_function_secret=test-secret", }, fsys)) - sql, err := afero.ReadFile(fsys, "supabase/schemas/tables/embedding-column.sql") + // Migration file should be in migrations dir with timestamp prefix. + migrationPath := filepath.Join(utils.MigrationsDir, "20260305120000_add-embedding-column.sql") + sql, err := afero.ReadFile(fsys, migrationPath) require.NoError(t, err) assert.Contains(t, string(sql), "alter table documents") - fn, err := afero.ReadFile(fsys, "supabase/functions/generate-embedding/index.ts") + fn, err := afero.ReadFile(fsys, filepath.Join(utils.FunctionsDir, "generate-embedding", "index.ts")) require.NoError(t, err) assert.Contains(t, string(fn), "documents") @@ -104,126 +114,49 @@ func TestAddRunWithLocalTemplate(t *testing.T) { assert.Contains(t, string(config), `[functions.generate-embedding]`) assert.Contains(t, string(config), `OPENAI_API_KEY = "env(OPENAI_API_KEY)"`) assert.Contains(t, string(config), `EMBEDDING_FUNCTION_SECRET = "env(EMBEDDING_FUNCTION_SECRET)"`) - assert.Contains(t, string(config), `./schemas/tables/*.sql`) functionEnv := readEnvMap(t, fsys, utils.FallbackEnvFilePath) assert.Equal(t, "test-key", functionEnv["OPENAI_API_KEY"]) assert.NotContains(t, functionEnv, "EMBEDDING_FUNCTION_SECRET") } -func TestAddRunWithEmbeddingsTemplateAndSchemaPlacement(t *testing.T) { +func TestAddRunWithMultipleMigrations(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteConfig(fsys, false)) - config, err := afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - config = append(config, []byte(` -[db.migrations.schema_placement] -"extensions" = "./schemas/db/extensions.sql" -"tables" = "./schemas/db/tables" -"functions" = "./schemas/db/functions/{name}.sql" -"triggers" = "./schemas/db/triggers/{name}.sql" -`)...) - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, config, 0644)) + origTimestamp := migrationTimestamp + migrationTimestamp = func(seq int) string { + return strings.Replace("20260305120000", "0000", strings.Repeat("0", 4-len(string(rune('0'+seq))))+string(rune('0'+seq)), 1) + } + t.Cleanup(func() { migrationTimestamp = origTimestamp }) template := `{ - "name": "add-embeddings", - "title": "Add Embeddings Support", - "description": "Adds embeddings support.", - "version": "4.3.5", - "inputs": { - "table_name": {"label": "Target table name", "type": "string", "required": true}, - "pk_column": {"label": "Primary key column", "type": "string", "default": "id"}, - "text_column": {"label": "Text column to embed", "type": "string", "required": true}, - "embedding_column": {"label": "Embedding column name", "type": "string", "default": "embedding"}, - "model": {"label": "Embedding model", "type": "select", "options": ["text-embedding-3-small", "text-embedding-3-large"], "default": "text-embedding-3-small"}, - "embedding_dims": {"label": "Override dimensions (optional)", "type": "number", "default": 1536}, - "openai_api_key": {"label": "OpenAI API key", "type": "password", "required": true}, - "embedding_function_secret": {"label": "Embedding function secret", "type": "password", "required": true} - }, + "name": "multi-migration", "steps": [ - { - "name": "configure_secrets", - "components": [ - {"name": "openai-api-key", "type": "secret", "key": "OPENAI_API_KEY", "value": "{{context.openai_api_key}}"}, - {"name": "embedding-function-secret", "type": "secret", "key": "EMBEDDING_FUNCTION_SECRET", "value": "{{context.embedding_function_secret}}"}, - {"name": "embedding-function-secret-vault", "type": "vault", "key": "EMBEDDING_FUNCTION_SECRET", "value": "{{context.embedding_function_secret}}"} - ] - }, - { - "name": "deploy_function", - "components": [ - { - "name": "generate-embedding", - "type": "edge_function", - "path": "./functions/generate-embedding", - "output": {"embedding_function_url": "{{generate-embedding.url}}"} - } - ] - }, { "name": "provision_database", "components": [ - {"name": "extensions", "type": "extensions", "path": "./schemas/extensions.sql"}, - {"name": "embedding-column", "type": "tables", "path": "./schemas/add-embedding-column.sql"}, - {"name": "trigger-function", "type": "functions", "path": "./schemas/queue-generate-embedding.sql"}, - {"name": "trigger", "type": "triggers", "path": "./schemas/on-insert-update-embedding.sql"} + {"name": "enable-extensions", "type": "migration", "path": "./sql/extensions.sql"}, + {"name": "create-tables", "type": "migration", "path": "./sql/tables.sql"} ] } ] }` - require.NoError(t, afero.WriteFile(fsys, "templates/add-embeddings.json", []byte(template), 0644)) - require.NoError(t, afero.WriteFile(fsys, "templates/schemas/extensions.sql", []byte(`create extension if not exists vector;`), 0644)) - require.NoError(t, afero.WriteFile(fsys, "templates/schemas/add-embedding-column.sql", []byte(`alter table {{context.table_name}} add column {{context.embedding_column}} vector({{context.embedding_dims}});`), 0644)) - require.NoError(t, afero.WriteFile(fsys, "templates/schemas/queue-generate-embedding.sql", []byte(`-- {{context.embedding_function_url}}`), 0644)) - require.NoError(t, afero.WriteFile(fsys, "templates/schemas/on-insert-update-embedding.sql", []byte(`create trigger trg after insert on {{context.table_name}} for each row execute function public.queue();`), 0644)) - require.NoError(t, afero.WriteFile(fsys, "templates/functions/generate-embedding/index.ts", []byte(`export const model = "{{context.model}}"`), 0644)) - - prevYes := viper.GetBool("YES") - viper.Set("YES", true) - t.Cleanup(func() { - viper.Set("YES", prevYes) - }) + require.NoError(t, afero.WriteFile(fsys, "templates/multi.json", []byte(template), 0644)) + require.NoError(t, afero.WriteFile(fsys, "templates/sql/extensions.sql", []byte(`create extension if not exists vector;`), 0644)) + require.NoError(t, afero.WriteFile(fsys, "templates/sql/tables.sql", []byte(`create table public.items (id bigint primary key);`), 0644)) - require.NoError(t, Run(context.Background(), "templates/add-embeddings.json", []string{ - "table_name=documents", - "text_column=content", - "openai_api_key=test-key", - "embedding_function_secret=test-secret", - }, fsys)) - - extensionsPath := filepath.Join(utils.SupabaseDirPath, "schemas", "db", "extensions.sql") - extensions, err := afero.ReadFile(fsys, extensionsPath) - require.NoError(t, err) - assert.Contains(t, string(extensions), "create extension") + require.NoError(t, Run(context.Background(), "templates/multi.json", nil, fsys)) - tablePath := filepath.Join(utils.SupabaseDirPath, "schemas", "db", "tables", "embedding-column.sql") - tableSql, err := afero.ReadFile(fsys, tablePath) + // Both migration files should exist. + entries, err := afero.ReadDir(fsys, utils.MigrationsDir) require.NoError(t, err) - assert.Contains(t, string(tableSql), "documents") - assert.Contains(t, string(tableSql), "embedding vector(1536)") + assert.Len(t, entries, 2) - functionPath := filepath.Join(utils.SupabaseDirPath, "schemas", "db", "functions", "trigger-function.sql") - functionSql, err := afero.ReadFile(fsys, functionPath) - require.NoError(t, err) - assert.Contains(t, string(functionSql), "/functions/v1/generate-embedding") - - triggerPath := filepath.Join(utils.SupabaseDirPath, "schemas", "db", "triggers", "trigger.sql") - triggerSql, err := afero.ReadFile(fsys, triggerPath) - require.NoError(t, err) - assert.Contains(t, string(triggerSql), "create trigger") - - functionEntry, err := afero.ReadFile(fsys, filepath.Join(utils.FunctionsDir, "generate-embedding", "index.ts")) - require.NoError(t, err) - assert.Contains(t, string(functionEntry), "text-embedding-3-small") - - config, err = afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - assert.Contains(t, string(config), `[functions.generate-embedding]`) - assert.Contains(t, string(config), `OPENAI_API_KEY = "env(OPENAI_API_KEY)"`) - assert.Contains(t, string(config), `EMBEDDING_FUNCTION_SECRET = "env(EMBEDDING_FUNCTION_SECRET)"`) - assert.Contains(t, string(config), `./schemas/tables/*.sql`) - assert.Contains(t, string(config), `"tables" = "./schemas/db/tables"`) + // Files should have different timestamps due to sequence counter. + names := []string{entries[0].Name(), entries[1].Name()} + assert.Contains(t, names[0], "enable-extensions") + assert.Contains(t, names[1], "create-tables") } func TestAddRunWithEdgeFunctionPathArray(t *testing.T) { @@ -307,12 +240,12 @@ func TestAddRunWithEdgeFunctionPathArraySharedSibling(t *testing.T) { assert.True(t, os.IsNotExist(err)) } -func TestAddRunFallsBackForUnsupportedComponentType(t *testing.T) { +func TestAddRunUnsupportedComponentTypeReturnsError(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteConfig(fsys, false)) template := `{ - "name": "unsupported-type-fallback", + "name": "bad-type", "steps": [ { "name": "provision_database", @@ -322,15 +255,12 @@ func TestAddRunFallsBackForUnsupportedComponentType(t *testing.T) { } ] }` - require.NoError(t, afero.WriteFile(fsys, "templates/fallback.json", []byte(template), 0644)) + require.NoError(t, afero.WriteFile(fsys, "templates/bad.json", []byte(template), 0644)) require.NoError(t, afero.WriteFile(fsys, "templates/schemas/stripe-schema.sql", []byte(`create schema if not exists stripe;`), 0644)) - require.NoError(t, Run(context.Background(), "templates/fallback.json", nil, fsys)) - - outPath := filepath.Join(utils.SchemasDir, "stripe-schema.sql") - sql, err := afero.ReadFile(fsys, outPath) - require.NoError(t, err) - assert.Contains(t, string(sql), "create schema if not exists stripe") + err := Run(context.Background(), "templates/bad.json", nil, fsys) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported component type: schema") } func TestAddRunSecretAppendsToExistingFunctionsEnv(t *testing.T) { @@ -363,143 +293,6 @@ func TestAddRunSecretAppendsToExistingFunctionsEnv(t *testing.T) { assert.Equal(t, "appended-value", functionEnv["OPENAI_API_KEY"]) } -func TestAddRunAppendsAndDedupesSqlTablePatch(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - cfg, err := afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - cfg = append(cfg, []byte(` -[db.migrations.schema_placement] -"tables" = "./schemas/tables/{name}.sql" -`)...) - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, cfg, 0644)) - - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.SchemasDir, "tables", "tasks.sql"), []byte(` -create table public.tasks ( - id bigint generated by default as identity primary key, - title text not null -); -`), 0644)) - - template := `{ - "name": "table-merge", - "inputs": { - "table_name": {"type": "string", "required": true}, - "embedding_column": {"type": "string", "default": "embedding"}, - "embedding_dims": {"type": "number", "default": 1536} - }, - "steps": [ - { - "name": "provision_database", - "components": [ - { - "name": "{{context.table_name}}", - "type": "tables", - "path": "./sql/add-embedding-column.sql" - } - ] - } - ] -}` - require.NoError(t, afero.WriteFile(fsys, "templates/table-merge.json", []byte(template), 0644)) - require.NoError(t, afero.WriteFile(fsys, "templates/sql/add-embedding-column.sql", []byte(` -alter table {{context.table_name}} - add column if not exists {{context.embedding_column}} vector({{context.embedding_dims}}); - -create index if not exists idx_{{context.table_name}}_{{context.embedding_column}} - on {{context.table_name}} - using hnsw ({{context.embedding_column}} vector_cosine_ops); -`), 0644)) - - require.NoError(t, Run(context.Background(), "templates/table-merge.json", []string{ - "table_name=tasks", - }, fsys)) - require.NoError(t, Run(context.Background(), "templates/table-merge.json", []string{ - "table_name=tasks", - }, fsys)) - - tableSQL, err := afero.ReadFile(fsys, filepath.Join(utils.SchemasDir, "tables", "tasks.sql")) - require.NoError(t, err) - sqlText := strings.ToLower(string(tableSQL)) - assert.Contains(t, sqlText, `create table`) - assert.Contains(t, sqlText, `vector(1536)`) - assert.Contains(t, sqlText, `create index if not exists idx_tasks_embedding`) - assert.NotContains(t, sqlText, "alter table tasks") - assert.Equal(t, 1, strings.Count(sqlText, "idx_tasks_embedding")) - - _, err = fsys.Stat(filepath.Join(utils.SchemasDir, "tables", "add-embedding-column.sql")) - assert.Error(t, err) -} - -func TestMergeSQLFileMergesBasicAlterTableVariants(t *testing.T) { - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.SchemasDir, "tables", "events.sql") - require.NoError(t, utils.MkdirIfNotExistFS(fsys, filepath.Dir(path))) - require.NoError(t, afero.WriteFile(fsys, path, []byte(` -create table public.events ( - id bigint not null, - payload text -); -`), 0644)) - - firstPatch := ` -alter table public.events alter column id add generated by default as identity ( - start with 1 - increment by 1 -); -alter table public.events alter column payload set default 'x'::text; -alter table public.events alter column payload set not null; -alter table public.events add constraint events_payload_key unique (payload); -` - changed, err := mergeSQLFile(path, firstPatch, fsys) - require.NoError(t, err) - assert.True(t, changed) - - changed, err = mergeSQLFile(path, firstPatch, fsys) - require.NoError(t, err) - assert.False(t, changed) - - secondPatch := ` -alter table public.events alter column payload drop default; -alter table public.events alter column payload drop not null; -alter table public.events drop constraint events_payload_key; -alter table public.events alter column payload type varchar(255); -` - changed, err = mergeSQLFile(path, secondPatch, fsys) - require.NoError(t, err) - assert.True(t, changed) - - sql, err := afero.ReadFile(fsys, path) - require.NoError(t, err) - sqlText := strings.ToLower(string(sql)) - assert.Contains(t, sqlText, "generated by default as identity") - assert.Contains(t, sqlText, "varchar(255)") - assert.NotContains(t, sqlText, "events_payload_key") - assert.NotContains(t, sqlText, "default 'x'::text") - assert.NotContains(t, sqlText, "alter table public.events") -} - -func TestMergeSQLFileFallsBackForUnsupportedAlterTable(t *testing.T) { - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.SchemasDir, "tables", "flags.sql") - require.NoError(t, utils.MkdirIfNotExistFS(fsys, filepath.Dir(path))) - require.NoError(t, afero.WriteFile(fsys, path, []byte(` -create table public.flags ( - id bigint primary key -); -`), 0644)) - - changed, err := mergeSQLFile(path, `alter table public.flags enable row level security;`, fsys) - require.NoError(t, err) - assert.True(t, changed) - - sql, err := afero.ReadFile(fsys, path) - require.NoError(t, err) - sqlText := strings.ToLower(string(sql)) - assert.Contains(t, sqlText, "create table public.flags") - assert.Contains(t, sqlText, "alter table public.flags enable row level security") -} - func TestAddRunShowsPostInstallMessage(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteConfig(fsys, false)) @@ -513,7 +306,7 @@ func TestAddRunShowsPostInstallMessage(t *testing.T) { "steps": [], "postInstall": { "title": "Complete setup for {{inputs.webhook_events}}", - "message": "Call: {{env.SUPABASE_URL}}/functions/v1/stripe-setup\\nrun_backfill={{inputs.run_backfill}}" + "message": "Call: {{env.SUPABASE_URL}}/functions/v1/stripe-setup\nrun_backfill={{inputs.run_backfill}}" } }` require.NoError(t, afero.WriteFile(fsys, "templates/post-install-template.json", []byte(template), 0644)) diff --git a/internal/add/config.go b/internal/add/config.go index a5f7e604c..88e36dca8 100644 --- a/internal/add/config.go +++ b/internal/add/config.go @@ -1,10 +1,8 @@ package add import ( - "bytes" "fmt" "os" - "path/filepath" "regexp" "strings" @@ -17,11 +15,8 @@ const ( sectionFunctionsPrefix = "functions." sectionEdgeSecrets = "edge_runtime.secrets" sectionDbVault = "db.vault" - sectionDbMigrations = "db.migrations" ) -var schemaPathsPattern = regexp.MustCompile(`(?s)\nschema_paths = \[(.*?)\]\n`) - type configEditor struct { data []byte changed bool @@ -60,30 +55,6 @@ func (e *configEditor) ensureSecretConfig(section, key string) { e.ensureKV(section, key, fmt.Sprintf("env(%s)", key), true) } -func (e *configEditor) ensureDefaultSchemaPaths() { - paths := []string{ - "./schemas/types.sql", - "./schemas/tables/*.sql", - "./schemas/functions/*.sql", - "./schemas/triggers/*.sql", - "./schemas/policies/*.sql", - "./schemas/*.sql", - } - lines := []string{"schema_paths = ["} - for _, fp := range paths { - lines = append(lines, fmt.Sprintf(` "%s",`, filepath.ToSlash(fp))) - } - lines = append(lines, "]") - block := strings.Join(lines, "\n") - if out := schemaPathsPattern.ReplaceAllLiteral(e.data, []byte("\n"+block+"\n")); !bytes.Equal(out, e.data) { - e.data = out - e.changed = true - return - } - inserted := e.insertSectionContent(sectionDbMigrations, block+"\n") - e.changed = e.changed || inserted -} - func (e *configEditor) ensureKV(section, key, value string, quoted bool) { valueExpr := value if quoted { @@ -115,26 +86,6 @@ func (e *configEditor) insertKVLine(section, key, line string) bool { return true } -func (e *configEditor) insertSectionContent(section, content string) bool { - start, end, found := findSectionBounds(string(e.data), section) - if !found { - return e.appendSection(section, content) - } - sectionBody := string(e.data[start:end]) - if strings.Contains(sectionBody, content) { - return false - } - insert := content - if len(sectionBody) > 0 && !strings.HasSuffix(sectionBody, "\n") { - insert = "\n" + insert - } - updated := append([]byte{}, e.data[:end]...) - updated = append(updated, []byte(insert)...) - updated = append(updated, e.data[end:]...) - e.data = updated - return true -} - func (e *configEditor) appendSection(section, content string) bool { body := string(e.data) if !strings.HasSuffix(body, "\n") { diff --git a/internal/add/types.go b/internal/add/types.go index e5e881b2e..17ed95a69 100644 --- a/internal/add/types.go +++ b/internal/add/types.go @@ -42,7 +42,6 @@ type TemplateComponent struct { Path TemplatePath `json:"path"` Key string `json:"key"` Value string `json:"value"` - Schema string `json:"schema"` Output map[string]string `json:"output"` }