diff --git a/frontend/js/api.js b/frontend/js/api.js index 9647168..731b72b 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -215,13 +215,13 @@ export const API = { /** * Activates a specific version. * @param {string} functionId - Function ID - * @param {number} version - Version number to activate - * @returns {Promise} The updated function + * @param {string} versionId - Version ID to activate + * @returns {Promise} */ - activate: (functionId, version) => + activate: (functionId, versionId) => apiRequest({ method: "POST", - url: `/api/functions/${functionId}/versions/${version}/activate`, + url: `/api/functions/${functionId}/versions/${versionId}/activate`, }), /** @@ -236,6 +236,18 @@ export const API = { method: "GET", url: `/api/functions/${functionId}/diff/${v1}/${v2}`, }), + + /** + * Deletes a specific version. + * @param {string} functionId - Function ID + * @param {string} versionId - Version ID to delete + * @returns {Promise} + */ + delete: (functionId, versionId) => + apiRequest({ + method: "DELETE", + url: `/api/functions/${functionId}/versions/${versionId}`, + }), }, /** diff --git a/frontend/js/i18n/locales/en.js b/frontend/js/i18n/locales/en.js index 02143fe..69816b2 100644 --- a/frontend/js/i18n/locales/en.js +++ b/frontend/js/i18n/locales/en.js @@ -350,6 +350,11 @@ export default { selectToCompare: "Select 2 versions to compare", compareVersions: "Compare v{{v1}} and v{{v2}}", versionsCount: "{{count}} versions", + deleteConfirm: + "Delete version {{version}}? This will also delete all executions for this version. This action cannot be undone.", + versionDeleted: "Version {{version}} deleted", + failedToDelete: "Failed to delete version", + delete: "Delete", }, // AI Request viewer diff --git a/frontend/js/i18n/locales/pt-BR.js b/frontend/js/i18n/locales/pt-BR.js index d78a8e4..4132f0a 100644 --- a/frontend/js/i18n/locales/pt-BR.js +++ b/frontend/js/i18n/locales/pt-BR.js @@ -353,6 +353,11 @@ export default { selectToCompare: "Selecione 2 versões para comparar", compareVersions: "Comparar v{{v1}} e v{{v2}}", versionsCount: "{{count}} versões", + deleteConfirm: + "Excluir versão {{version}}? Isso também excluirá todas as execuções desta versão. Esta ação não pode ser desfeita.", + versionDeleted: "Versão {{version}} excluída", + failedToDelete: "Falha ao excluir versão", + delete: "Excluir", }, // AI Request viewer diff --git a/frontend/js/views/function-versions.js b/frontend/js/views/function-versions.js index 854227a..8eb795c 100644 --- a/frontend/js/views/function-versions.js +++ b/frontend/js/views/function-versions.js @@ -187,20 +187,51 @@ export const FunctionVersions = { /** * Activates a specific version. - * @param {number} version - Version number to activate + * @param {FunctionVersion} ver - Version object to activate * @returns {Promise} */ - activateVersion: async (version) => { - if (!confirm(t("versionsPage.activateConfirm", { version }))) return; + activateVersion: async (ver) => { + if (!confirm(t("versionsPage.activateConfirm", { version: ver.version }))) { + return; + } try { - await API.versions.activate(FunctionVersions.func.id, version); - Toast.show(t("versionsPage.versionActivated", { version }), "success"); + await API.versions.activate(FunctionVersions.func.id, ver.id); + Toast.show( + t("versionsPage.versionActivated", { version: ver.version }), + "success", + ); await FunctionVersions.loadData(FunctionVersions.func.id); } catch (e) { Toast.show(t("versionsPage.failedToActivate"), "error"); } }, + /** + * Deletes a specific version. + * @param {FunctionVersion} ver - Version object to delete + * @returns {Promise} + */ + deleteVersion: async (ver) => { + if (!confirm(t("versionsPage.deleteConfirm", { version: ver.version }))) { + return; + } + try { + await API.versions.delete(FunctionVersions.func.id, ver.id); + Toast.show( + t("versionsPage.versionDeleted", { version: ver.version }), + "success", + ); + // Remove from selected versions if it was selected + const idx = FunctionVersions.selectedVersions.indexOf(ver.version); + if (idx !== -1) { + FunctionVersions.selectedVersions.splice(idx, 1); + } + await FunctionVersions.loadVersions(); + } catch (e) { + Toast.show(t("versionsPage.failedToDelete"), "error"); + } + }, + /** * Navigates to the version diff view for selected versions. */ @@ -345,21 +376,33 @@ export const FunctionVersions = { ]), m(TableCell, formatUnixTimestamp(ver.created_at)), m(TableCell, { align: "right" }, [ - ver.version !== func.active_version.version && - m( - Button, - { - variant: ButtonVariant.OUTLINE, - size: ButtonSize.SM, - onclick: (e) => { - e.stopPropagation(); - FunctionVersions.activateVersion( - ver.version, - ); + ver.version !== func.active_version.version && [ + m( + Button, + { + variant: ButtonVariant.OUTLINE, + size: ButtonSize.SM, + onclick: (e) => { + e.stopPropagation(); + FunctionVersions.activateVersion(ver); + }, }, - }, - t("versionsPage.activate"), - ), + t("versionsPage.activate"), + ), + m( + Button, + { + variant: ButtonVariant.DESTRUCTIVE, + size: ButtonSize.SM, + style: "margin-left: 0.5rem;", + onclick: (e) => { + e.stopPropagation(); + FunctionVersions.deleteVersion(ver); + }, + }, + t("versionsPage.delete"), + ), + ], ]), ], ) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index d802833..be692ee 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -487,7 +487,7 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/functions/{id}/versions/{version}/activate: + /api/functions/{id}/versions/{versionId}/activate: parameters: - name: id in: path @@ -495,13 +495,13 @@ paths: description: Unique identifier of the function schema: type: string - - name: version + - name: versionId in: path required: true - description: Version number to activate + description: Unique identifier of the version (primary key) schema: - type: integer - minimum: 1 + type: string + example: "ver_abc123_v1" post: tags: @@ -518,6 +518,70 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + "404": + description: Version not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/functions/{id}/versions/{versionId}: + parameters: + - name: id + in: path + required: true + description: Unique identifier of the function + schema: + type: string + - name: versionId + in: path + required: true + description: Unique identifier of the version (primary key) + schema: + type: string + example: "ver_abc123_v1" + + delete: + tags: + - Versions + summary: Delete a version + description: | + Permanently deletes a specific version of a function. + The active version cannot be deleted - you must activate a different version first. + Deleting a version will also delete all executions associated with that version. + operationId: deleteVersion + responses: + "204": + description: Version deleted successfully + "400": + description: Cannot delete active version + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + activeVersion: + summary: Attempting to delete active version + value: + error: "Cannot delete active version" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Version not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 156784f..e14a4ea 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -417,23 +417,42 @@ func GetVersionHandler(database store.DB) http.HandlerFunc { // ActivateVersionHandler returns a handler for activating a version func ActivateVersionHandler(database store.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - versionStr := r.PathValue("version") + versionID := r.PathValue("versionId") - // Parse version number - versionNum, err := strconv.Atoi(versionStr) - if err != nil { - writeError(w, http.StatusBadRequest, "Invalid version number") + // Activate the version + if err := database.ActivateVersion(r.Context(), versionID); err != nil { + if err == store.ErrVersionNotFound { + writeError(w, http.StatusNotFound, "Version not found") + return + } + writeError(w, http.StatusInternalServerError, "Failed to activate version") return } - // Activate the version - if err := database.ActivateVersion(r.Context(), id, versionNum); err != nil { - writeError(w, http.StatusNotFound, "Version not found") + w.WriteHeader(http.StatusOK) + } +} + +// DeleteVersionHandler returns a handler for deleting a version +func DeleteVersionHandler(database store.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + versionID := r.PathValue("versionId") + + // Delete the version + if err := database.DeleteVersion(r.Context(), versionID); err != nil { + if err == store.ErrVersionNotFound { + writeError(w, http.StatusNotFound, "Version not found") + return + } + if err == store.ErrCannotDeleteActiveVersion { + writeError(w, http.StatusBadRequest, "Cannot delete active version") + return + } + writeError(w, http.StatusInternalServerError, "Failed to delete version") return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } } diff --git a/internal/api/server.go b/internal/api/server.go index c3c6eb4..6e1e9ff 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -104,7 +104,8 @@ func (s *Server) setupRoutes() { // Version Management - only need DB s.mux.Handle("GET /api/functions/{id}/versions", authMiddleware(http.HandlerFunc(ListVersionsHandler(s.db)))) s.mux.Handle("GET /api/functions/{id}/versions/{version}", authMiddleware(http.HandlerFunc(GetVersionHandler(s.db)))) - s.mux.Handle("POST /api/functions/{id}/versions/{version}/activate", authMiddleware(http.HandlerFunc(ActivateVersionHandler(s.db)))) + s.mux.Handle("POST /api/functions/{id}/versions/{versionId}/activate", authMiddleware(http.HandlerFunc(ActivateVersionHandler(s.db)))) + s.mux.Handle("DELETE /api/functions/{id}/versions/{versionId}", authMiddleware(http.HandlerFunc(DeleteVersionHandler(s.db)))) s.mux.Handle("GET /api/functions/{id}/diff/{v1}/{v2}", authMiddleware(http.HandlerFunc(GetVersionDiffHandler(s.db)))) // Execution History - only need DB diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 22c8c9f..e83b411 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -323,10 +323,11 @@ func TestActivateVersion(t *testing.T) { // Create a test function and two versions fn := createTestFunction(t, database) - createTestVersion(t, database, fn.ID, "function handler(ctx, event)\n return {statusCode = 200}\nend") + v1 := createTestVersion(t, database, fn.ID, "function handler(ctx, event)\n return {statusCode = 200}\nend") createTestVersion(t, database, fn.ID, "function handler(ctx, event)\n return {statusCode = 201}\nend") - req := makeAuthRequest(http.MethodPost, "/api/functions/"+fn.ID+"/versions/1/activate", nil) + // Use version ID (primary key) instead of version number + req := makeAuthRequest(http.MethodPost, "/api/functions/"+fn.ID+"/versions/"+v1.ID+"/activate", nil) w := httptest.NewRecorder() server.Handler().ServeHTTP(w, req) diff --git a/internal/store/memory.go b/internal/store/memory.go index c35a46d..c1aa6fa 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -256,27 +256,73 @@ func (db *MemoryDB) GetActiveVersion(_ context.Context, functionID string) (Func return FunctionVersion{}, ErrNoActiveVersion } -func (db *MemoryDB) ActivateVersion(_ context.Context, functionID string, version int) error { +func (db *MemoryDB) ActivateVersion(_ context.Context, versionID string) error { db.mu.Lock() defer db.mu.Unlock() - versions := db.versions[functionID] - found := false + // Find the version by ID across all functions + var targetFunctionID string + var targetIdx = -1 + + for funcID, versions := range db.versions { + for i, v := range versions { + if v.ID == versionID { + targetFunctionID = funcID + targetIdx = i + break + } + } + if targetIdx != -1 { + break + } + } + if targetIdx == -1 { + return ErrVersionNotFound + } + + // Deactivate all versions and activate the target + versions := db.versions[targetFunctionID] for i := range versions { - if versions[i].Version == version { - versions[i].IsActive = true - found = true - } else { - versions[i].IsActive = false + versions[i].IsActive = (i == targetIdx) + } + + db.versions[targetFunctionID] = versions + return nil +} + +func (db *MemoryDB) DeleteVersion(_ context.Context, versionID string) error { + db.mu.Lock() + defer db.mu.Unlock() + + // Find the version by ID across all functions + var targetFunctionID string + var targetIdx = -1 + + for funcID, versions := range db.versions { + for i, v := range versions { + if v.ID == versionID { + // Check if it's the active version + if v.IsActive { + return ErrCannotDeleteActiveVersion + } + targetFunctionID = funcID + targetIdx = i + break + } + } + if targetIdx != -1 { + break } } - if !found { + if targetIdx == -1 { return ErrVersionNotFound } - db.versions[functionID] = versions + // Remove the version from the slice + versions := db.versions[targetFunctionID] + db.versions[targetFunctionID] = append(versions[:targetIdx], versions[targetIdx+1:]...) return nil } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index cf7876a..e528211 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -436,35 +436,33 @@ func (db *SQLiteDB) GetActiveVersion(ctx context.Context, functionID string) (Fu return v, nil } -func (db *SQLiteDB) ActivateVersion(ctx context.Context, functionID string, version int) error { +func (db *SQLiteDB) ActivateVersion(ctx context.Context, versionID string) error { tx, err := db.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() - // Check if the version exists - var exists bool + // Get the function_id for this version + var functionID string err = tx.QueryRowContext(ctx, - "SELECT EXISTS(SELECT 1 FROM function_versions WHERE function_id = ? AND version = ?)", - functionID, version).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check version existence: %w", err) - } - if !exists { + "SELECT function_id FROM function_versions WHERE id = ?", + versionID).Scan(&functionID) + if errors.Is(err, sql.ErrNoRows) { return ErrVersionNotFound } + if err != nil { + return fmt.Errorf("failed to get version: %w", err) + } - // Deactivate all versions + // Deactivate all versions for this function _, err = tx.ExecContext(ctx, "UPDATE function_versions SET is_active = 0 WHERE function_id = ?", functionID) if err != nil { return fmt.Errorf("failed to deactivate versions: %w", err) } // Activate the specified version - _, err = tx.ExecContext(ctx, - "UPDATE function_versions SET is_active = 1 WHERE function_id = ? AND version = ?", - functionID, version) + _, err = tx.ExecContext(ctx, "UPDATE function_versions SET is_active = 1 WHERE id = ?", versionID) if err != nil { return fmt.Errorf("failed to activate version: %w", err) } @@ -472,6 +470,39 @@ func (db *SQLiteDB) ActivateVersion(ctx context.Context, functionID string, vers return tx.Commit() } +func (db *SQLiteDB) DeleteVersion(ctx context.Context, versionID string) error { + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Check if the version exists and if it's active + var isActive bool + err = tx.QueryRowContext(ctx, + "SELECT is_active FROM function_versions WHERE id = ?", + versionID).Scan(&isActive) + if errors.Is(err, sql.ErrNoRows) { + return ErrVersionNotFound + } + if err != nil { + return fmt.Errorf("failed to check version: %w", err) + } + + // Prevent deletion of active version + if isActive { + return ErrCannotDeleteActiveVersion + } + + // Delete the version + _, err = tx.ExecContext(ctx, "DELETE FROM function_versions WHERE id = ?", versionID) + if err != nil { + return fmt.Errorf("failed to delete version: %w", err) + } + + return tx.Commit() +} + // Execution operations func (db *SQLiteDB) CreateExecution(ctx context.Context, exec Execution) (Execution, error) { diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 5d01301..58ae3c3 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -478,15 +478,16 @@ func TestSQLiteDB_ActivateVersion(t *testing.T) { } // Create 2 versions - if _, err := sqliteDB.CreateVersion(ctx, fn.ID, "v1", nil); err != nil { + v1, err := sqliteDB.CreateVersion(ctx, fn.ID, "v1", nil) + if err != nil { t.Fatalf("CreateVersion v1 failed: %v", err) } if _, err := sqliteDB.CreateVersion(ctx, fn.ID, "v2", nil); err != nil { t.Fatalf("CreateVersion v2 failed: %v", err) } - // Activate v1 - if err := sqliteDB.ActivateVersion(ctx, fn.ID, 1); err != nil { + // Activate v1 using version ID + if err := sqliteDB.ActivateVersion(ctx, v1.ID); err != nil { t.Fatalf("ActivateVersion failed: %v", err) } diff --git a/internal/store/store.go b/internal/store/store.go index a46757c..a28196d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,10 +9,11 @@ import ( ) var ( - ErrFunctionNotFound = errors.New("function not found") - ErrVersionNotFound = errors.New("version not found") - ErrNoActiveVersion = errors.New("no active version") - ErrExecutionNotFound = errors.New("execution not found") + ErrFunctionNotFound = errors.New("function not found") + ErrVersionNotFound = errors.New("version not found") + ErrNoActiveVersion = errors.New("no active version") + ErrExecutionNotFound = errors.New("execution not found") + ErrCannotDeleteActiveVersion = errors.New("cannot delete active version") ) // DB defines the database interface for the Lunar API. @@ -55,9 +56,14 @@ type DB interface { // Returns ErrNoActiveVersion if no version is active. GetActiveVersion(ctx context.Context, functionID string) (FunctionVersion, error) - // ActivateVersion sets a specific version as the active version. + // ActivateVersion sets a specific version as the active version by its ID. // Returns ErrVersionNotFound if the version does not exist. - ActivateVersion(ctx context.Context, functionID string, version int) error + ActivateVersion(ctx context.Context, versionID string) error + + // DeleteVersion removes a specific version by its ID. + // Returns ErrVersionNotFound if the version does not exist. + // Returns ErrCannotDeleteActiveVersion if attempting to delete the active version. + DeleteVersion(ctx context.Context, versionID string) error // CreateExecution records a new execution. Returns the execution with // timestamps populated.